From d9bc2816eed2e2d217a6e59a83a86109cedec2d5 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Fri, 6 Mar 2026 15:44:30 +0000 Subject: [PATCH 01/11] feat: add s3 library from data platform This will ensure that the relation can integrate with other charms. --- lib/charms/data_platform_libs/v0/s3.py | 792 +++++++++++++++++++++++++ 1 file changed, 792 insertions(+) create mode 100644 lib/charms/data_platform_libs/v0/s3.py diff --git a/lib/charms/data_platform_libs/v0/s3.py b/lib/charms/data_platform_libs/v0/s3.py new file mode 100644 index 0000000..dbf4d5b --- /dev/null +++ b/lib/charms/data_platform_libs/v0/s3.py @@ -0,0 +1,792 @@ +# Copyright 2023 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +r"""A library for communicating with the S3 credentials providers and consumers. + +This library provides the relevant interface code implementing the communication +specification for fetching, retrieving, triggering, and responding to events related to +the S3 provider charm and its consumers. + +### Provider charm + +The provider is implemented in the `s3-provider` charm which is meant to be deployed +alongside one or more consumer charms. The provider charm is serving the s3 credentials and +metadata needed to communicate and work with an S3 compatible backend. + +Example: +```python + +from charms.data_platform_libs.v0.s3 import CredentialRequestedEvent, S3Provider + + +class ExampleProviderCharm(CharmBase): + def __init__(self, *args) -> None: + super().__init__(*args) + self.s3_provider = S3Provider(self, "s3-credentials") + + self.framework.observe(self.s3_provider.on.credentials_requested, + self._on_credential_requested) + + def _on_credential_requested(self, event: CredentialRequestedEvent): + if not self.unit.is_leader(): + return + + # get relation id + relation_id = event.relation.id + + # get bucket name + bucket = event.bucket + + # S3 configuration parameters + desired_configuration = {"access-key": "your-access-key", "secret-key": + "your-secret-key", "bucket": "your-bucket"} + + # update the configuration + self.s3_provider.update_connection_info(relation_id, desired_configuration) + + # or it is possible to set each field independently + + self.s3_provider.set_secret_key(relation_id, "your-secret-key") + + +if __name__ == "__main__": + main(ExampleProviderCharm) + + +### Requirer charm + +The requirer charm is the charm requiring the S3 credentials. +An example of requirer charm is the following: + +Example: +```python + +from charms.data_platform_libs.v0.s3 import ( + CredentialsChangedEvent, + CredentialsGoneEvent, + S3Requirer +) + +class ExampleRequirerCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + + bucket_name = "test-bucket" + # if bucket name is not provided the bucket name will be generated + # e.g., ('relation-{relation.id}') + + self.s3_client = S3Requirer(self, "s3-credentials", bucket_name) + + self.framework.observe(self.s3_client.on.credentials_changed, self._on_credential_changed) + self.framework.observe(self.s3_client.on.credentials_gone, self._on_credential_gone) + + def _on_credential_changed(self, event: CredentialsChangedEvent): + + # access single parameter credential + secret_key = event.secret_key + access_key = event.access_key + + # or as alternative all credentials can be collected as a dictionary + credentials = self.s3_client.get_s3_credentials() + + def _on_credential_gone(self, event: CredentialsGoneEvent): + # credentials are removed + pass + + if __name__ == "__main__": + main(ExampleRequirerCharm) +``` + +""" + +import json +import logging +from collections import namedtuple +from typing import Dict, List, Optional, Union + +import ops.charm +import ops.framework +import ops.model +from ops.charm import ( + CharmBase, + CharmEvents, + RelationBrokenEvent, + RelationChangedEvent, + RelationEvent, + RelationJoinedEvent, +) +from ops.framework import EventSource, Object, ObjectEvents +from ops.model import Application, Relation, RelationDataContent, Unit + +# The unique Charmhub library identifier, never change it +LIBID = "fca396f6254246c9bfa565b1f85ab528" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 6 + +logger = logging.getLogger(__name__) + +Diff = namedtuple("Diff", "added changed deleted") +Diff.__doc__ = """ +A tuple for storing the diff between two data mappings. + +added - keys that were added +changed - keys that still exist but have new values +deleted - key that were deleted""" + + +def diff(event: RelationChangedEvent, bucket: Union[Unit, Application]) -> Diff: + """Retrieves the diff of the data in the relation changed databag. + + Args: + event: relation changed event. + bucket: bucket of the databag (app or unit) + + Returns: + a Diff instance containing the added, deleted and changed + keys from the event relation databag. + """ + # Retrieve the old data from the data key in the application relation databag. + old_data = json.loads(event.relation.data[bucket].get("data", "{}")) + # Retrieve the new data from the event relation databag. + new_data = ( + {key: value for key, value in event.relation.data[event.app].items() if key != "data"} + if event.app + else {} + ) + + # These are the keys that were added to the databag and triggered this event. + added = new_data.keys() - old_data.keys() + # These are the keys that were removed from the databag and triggered this event. + deleted = old_data.keys() - new_data.keys() + # These are the keys that already existed in the databag, + # but had their values changed. + changed = {key for key in old_data.keys() & new_data.keys() if old_data[key] != new_data[key]} + + # TODO: evaluate the possibility of losing the diff if some error + # happens in the charm before the diff is completely checked (DPE-412). + # Convert the new_data to a serializable format and save it for a next diff check. + event.relation.data[bucket].update({"data": json.dumps(new_data)}) + + # Return the diff with all possible changes. + return Diff(added, changed, deleted) + + +class BucketEvent(RelationEvent): + """Base class for bucket events.""" + + @property + def bucket(self) -> Optional[str]: + """Returns the bucket was requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("bucket", "") + + +class CredentialRequestedEvent(BucketEvent): + """Event emitted when a set of credential is requested for use on this relation.""" + + +class S3CredentialEvents(CharmEvents): + """Event descriptor for events raised by S3Provider.""" + + credentials_requested = EventSource(CredentialRequestedEvent) + + +class S3Provider(Object): + """A provider handler for communicating S3 credentials to consumers.""" + + on = S3CredentialEvents() # pyright: ignore [reportAssignmentType] + + def __init__( + self, + charm: CharmBase, + relation_name: str, + ): + super().__init__(charm, relation_name) + self.charm = charm + self.local_app = self.charm.model.app + self.local_unit = self.charm.unit + self.relation_name = relation_name + + # monitor relation changed event for changes in the credentials + self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """React to the relation changed event by consuming data.""" + if not self.charm.unit.is_leader(): + return + diff = self._diff(event) + # emit on credential requested if bucket is provided by the requirer application + if "bucket" in diff.added: + getattr(self.on, "credentials_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + def _load_relation_data(self, raw_relation_data: dict) -> dict: + """Loads relation data from the relation data bag. + + Args: + raw_relation_data: Relation data from the databag + Returns: + dict: Relation data in dict format. + """ + connection_data = {} + for key in raw_relation_data: + try: + connection_data[key] = json.loads(raw_relation_data[key]) + except (json.decoder.JSONDecodeError, TypeError): + connection_data[key] = raw_relation_data[key] + return connection_data + + # def _diff(self, event: RelationChangedEvent) -> Diff: + # """Retrieves the diff of the data in the relation changed databag. + + # Args: + # event: relation changed event. + + # Returns: + # a Diff instance containing the added, deleted and changed + # keys from the event relation databag. + # """ + # # Retrieve the old data from the data key in the application relation databag. + # old_data = json.loads(event.relation.data[self.local_app].get("data", "{}")) + # # Retrieve the new data from the event relation databag. + # new_data = { + # key: value for key, value in event.relation.data[event.app].items() if key != "data" + # } + + # # These are the keys that were added to the databag and triggered this event. + # added = new_data.keys() - old_data.keys() + # # These are the keys that were removed from the databag and triggered this event. + # deleted = old_data.keys() - new_data.keys() + # # These are the keys that already existed in the databag, + # # but had their values changed. + # changed = { + # key for key in old_data.keys() & new_data.keys() if old_data[key] != new_data[key] + # } + + # # TODO: evaluate the possibility of losing the diff if some error + # # happens in the charm before the diff is completely checked (DPE-412). + # # Convert the new_data to a serializable format and save it for a next diff check. + # event.relation.data[self.local_app].update({"data": json.dumps(new_data)}) + + # # Return the diff with all possible changes. + # return Diff(added, changed, deleted) + + def _diff(self, event: RelationChangedEvent) -> Diff: + """Retrieves the diff of the data in the relation changed databag. + + Args: + event: relation changed event. + + Returns: + a Diff instance containing the added, deleted and changed + keys from the event relation databag. + """ + return diff(event, self.local_app) + + def fetch_relation_data(self) -> dict: + """Retrieves data from relation. + + This function can be used to retrieve data from a relation + in the charm code when outside an event callback. + + Returns: + a dict of the values stored in the relation data bag + for all relation instances (indexed by the relation id). + """ + data = {} + for relation in self.relations: + data[relation.id] = ( + {key: value for key, value in relation.data[relation.app].items() if key != "data"} + if relation.app + else {} + ) + return data + + def update_connection_info(self, relation_id: int, connection_data: dict) -> None: + """Updates the credential data as set of key-value pairs in the relation. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + connection_data: dict containing the key-value pairs + that should be updated. + """ + # check and write changes only if you are the leader + if not self.local_unit.is_leader(): + return + + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + return + + # configuration options that are list + s3_list_options = ["attributes", "tls-ca-chain"] + + # update the databag, if connection data did not change with respect to before + # the relation changed event is not triggered + updated_connection_data = {} + for configuration_option, configuration_value in connection_data.items(): + if configuration_option in s3_list_options: + updated_connection_data[configuration_option] = json.dumps(configuration_value) + else: + updated_connection_data[configuration_option] = configuration_value + + relation.data[self.local_app].update(updated_connection_data) + logger.debug("Updated S3 connection info.") + + @property + def relations(self) -> List[Relation]: + """The list of Relation instances associated with this relation_name.""" + return list(self.charm.model.relations[self.relation_name]) + + def set_bucket(self, relation_id: int, bucket: str) -> None: + """Sets bucket name in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + bucket: the bucket name. + """ + self.update_connection_info(relation_id, {"bucket": bucket}) + + def set_access_key(self, relation_id: int, access_key: str) -> None: + """Sets access-key value in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + access_key: the access-key value. + """ + self.update_connection_info(relation_id, {"access-key": access_key}) + + def set_secret_key(self, relation_id: int, secret_key: str) -> None: + """Sets the secret key value in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + secret_key: the value of the secret key. + """ + self.update_connection_info(relation_id, {"secret-key": secret_key}) + + def set_path(self, relation_id: int, path: str) -> None: + """Sets the path value in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + path: the path value. + """ + self.update_connection_info(relation_id, {"path": path}) + + def set_endpoint(self, relation_id: int, endpoint: str) -> None: + """Sets the endpoint address in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + endpoint: the endpoint address. + """ + self.update_connection_info(relation_id, {"endpoint": endpoint}) + + def set_region(self, relation_id: int, region: str) -> None: + """Sets the region location in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + region: the region address. + """ + self.update_connection_info(relation_id, {"region": region}) + + def set_s3_uri_style(self, relation_id: int, s3_uri_style: str) -> None: + """Sets the S3 URI style in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + s3_uri_style: the s3 URI style. + """ + self.update_connection_info(relation_id, {"s3-uri-style": s3_uri_style}) + + def set_storage_class(self, relation_id: int, storage_class: str) -> None: + """Sets the storage class in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + storage_class: the storage class. + """ + self.update_connection_info(relation_id, {"storage-class": storage_class}) + + def set_tls_ca_chain(self, relation_id: int, tls_ca_chain: List[str]) -> None: + """Sets the tls_ca_chain value in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + tls_ca_chain: the TLS Chain value. + """ + self.update_connection_info(relation_id, {"tls-ca-chain": tls_ca_chain}) + + def set_s3_api_version(self, relation_id: int, s3_api_version: str) -> None: + """Sets the S3 API version in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + s3_api_version: the S3 version value. + """ + self.update_connection_info(relation_id, {"s3-api-version": s3_api_version}) + + def set_delete_older_than_days(self, relation_id: int, days: int) -> None: + """Sets the retention days for full backups in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + days: the value. + """ + self.update_connection_info(relation_id, {"delete-older-than-days": str(days)}) + + def set_attributes(self, relation_id: int, attributes: List[str]) -> None: + """Sets the connection attributes in application databag. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + attributes: the attributes value. + """ + self.update_connection_info(relation_id, {"attributes": attributes}) + + +class S3Event(RelationEvent): + """Base class for S3 storage events.""" + + @property + def bucket(self) -> Optional[str]: + """Returns the bucket name.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("bucket") + + @property + def access_key(self) -> Optional[str]: + """Returns the access key.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("access-key") + + @property + def secret_key(self) -> Optional[str]: + """Returns the secret key.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("secret-key") + + @property + def path(self) -> Optional[str]: + """Returns the path where data can be stored.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("path") + + @property + def endpoint(self) -> Optional[str]: + """Returns the endpoint address.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("endpoint") + + @property + def region(self) -> Optional[str]: + """Returns the region.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("region") + + @property + def s3_uri_style(self) -> Optional[str]: + """Returns the s3 uri style.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("s3-uri-style") + + @property + def storage_class(self) -> Optional[str]: + """Returns the storage class name.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("storage-class") + + @property + def tls_ca_chain(self) -> Optional[List[str]]: + """Returns the TLS CA chain.""" + if not self.relation.app: + return None + + tls_ca_chain = self.relation.data[self.relation.app].get("tls-ca-chain") + if tls_ca_chain is not None: + return json.loads(tls_ca_chain) + return None + + @property + def s3_api_version(self) -> Optional[str]: + """Returns the S3 API version.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("s3-api-version") + + @property + def delete_older_than_days(self) -> Optional[int]: + """Returns the retention days for full backups.""" + if not self.relation.app: + return None + + days = self.relation.data[self.relation.app].get("delete-older-than-days") + if days is None: + return None + return int(days) + + @property + def attributes(self) -> Optional[List[str]]: + """Returns the attributes.""" + if not self.relation.app: + return None + + attributes = self.relation.data[self.relation.app].get("attributes") + if attributes is not None: + return json.loads(attributes) + return None + + +class CredentialsChangedEvent(S3Event): + """Event emitted when S3 credential are changed on this relation.""" + + +class CredentialsGoneEvent(RelationEvent): + """Event emitted when S3 credential are removed from this relation.""" + + +class S3CredentialRequiresEvents(ObjectEvents): + """Event descriptor for events raised by the S3Provider.""" + + credentials_changed = EventSource(CredentialsChangedEvent) + credentials_gone = EventSource(CredentialsGoneEvent) + + +S3_REQUIRED_OPTIONS = ["access-key", "secret-key"] + + +class S3Requirer(Object): + """Requires-side of the s3 relation.""" + + on = S3CredentialRequiresEvents() # pyright: ignore[reportAssignmentType] + + def __init__( + self, charm: ops.charm.CharmBase, relation_name: str, bucket_name: Optional[str] = None + ): + """Manager of the s3 client relations.""" + super().__init__(charm, relation_name) + + self.relation_name = relation_name + self.charm = charm + self.local_app = self.charm.model.app + self.local_unit = self.charm.unit + self.bucket = bucket_name + + self.framework.observe( + self.charm.on[self.relation_name].relation_changed, self._on_relation_changed + ) + + self.framework.observe( + self.charm.on[self.relation_name].relation_joined, self._on_relation_joined + ) + + self.framework.observe( + self.charm.on[self.relation_name].relation_broken, + self._on_relation_broken, + ) + + def _generate_bucket_name(self, event: RelationJoinedEvent): + """Returns the bucket name generated from relation id.""" + return f"relation-{event.relation.id}" + + def _on_relation_joined(self, event: RelationJoinedEvent) -> None: + """Event emitted when the application joins the s3 relation.""" + if self.bucket is None: + self.bucket = self._generate_bucket_name(event) + self.update_connection_info(event.relation.id, {"bucket": self.bucket}) + + def fetch_relation_data(self) -> dict: + """Retrieves data from relation. + + This function can be used to retrieve data from a relation + in the charm code when outside an event callback. + + Returns: + a dict of the values stored in the relation data bag + for all relation instances (indexed by the relation id). + """ + data = {} + + for relation in self.relations: + data[relation.id] = self._load_relation_data(relation.data[self.charm.app]) + return data + + def update_connection_info(self, relation_id: int, connection_data: dict) -> None: + """Updates the credential data as set of key-value pairs in the relation. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + connection_data: dict containing the key-value pairs + that should be updated. + """ + # check and write changes only if you are the leader + if not self.local_unit.is_leader(): + return + + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + return + + # update the databag, if connection data did not change with respect to before + # the relation changed event is not triggered + # configuration options that are list + s3_list_options = ["attributes", "tls-ca-chain"] + updated_connection_data = {} + for configuration_option, configuration_value in connection_data.items(): + if configuration_option in s3_list_options: + updated_connection_data[configuration_option] = json.dumps(configuration_value) + else: + updated_connection_data[configuration_option] = configuration_value + + relation.data[self.local_app].update(updated_connection_data) + logger.debug("Updated S3 credentials.") + + def _load_relation_data(self, raw_relation_data: RelationDataContent) -> Dict[str, str]: + """Loads relation data from the relation data bag. + + Args: + raw_relation_data: Relation data from the databag + Returns: + dict: Relation data in dict format. + """ + connection_data = {} + for key in raw_relation_data: + try: + connection_data[key] = json.loads(raw_relation_data[key]) + except (json.decoder.JSONDecodeError, TypeError): + connection_data[key] = raw_relation_data[key] + return connection_data + + def _diff(self, event: RelationChangedEvent) -> Diff: + """Retrieves the diff of the data in the relation changed databag. + + Args: + event: relation changed event. + + Returns: + a Diff instance containing the added, deleted and changed + keys from the event relation databag. + """ + return diff(event, self.local_unit) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Notify the charm about the presence of S3 credentials.""" + # check if the mandatory options are in the relation data + contains_required_options = True + # get current credentials data + credentials = self.get_s3_connection_info() + # records missing options + missing_options = [] + for configuration_option in S3_REQUIRED_OPTIONS: + if configuration_option not in credentials: + contains_required_options = False + missing_options.append(configuration_option) + # emit credential change event only if all mandatory fields are present + if contains_required_options: + getattr(self.on, "credentials_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + else: + logger.warning( + f"Some mandatory fields: {missing_options} are not present, do not emit credential change event!" + ) + + def get_s3_connection_info(self) -> Dict[str, str]: + """Return the s3 credentials as a dictionary.""" + for relation in self.relations: + if relation and relation.app: + return self._load_relation_data(relation.data[relation.app]) + + return {} + + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + """Notify the charm about a broken S3 credential store relation.""" + getattr(self.on, "credentials_gone").emit(event.relation, app=event.app, unit=event.unit) + + @property + def relations(self) -> List[Relation]: + """The list of Relation instances associated with this relation_name.""" + return list(self.charm.model.relations[self.relation_name]) From 8fe4fb644ba4fec622154cf5831296fc7083c9d7 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Fri, 6 Mar 2026 15:46:13 +0000 Subject: [PATCH 02/11] feat: scaffold out the charm with s3 integration --- charmcraft.yaml | 4 ++++ src/charm.py | 19 +++++++++++++++++++ tests/test_charm.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/charmcraft.yaml b/charmcraft.yaml index ba4784f..661842c 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -26,6 +26,10 @@ requires: interface: certificate_transfer limit: 1 optional: true + s3-backend: + interface: s3 + limit: 1 + optional: true peers: dbcluster: diff --git a/src/charm.py b/src/charm.py index 0eb869d..56cc4f1 100755 --- a/src/charm.py +++ b/src/charm.py @@ -15,6 +15,7 @@ from charms.certificate_transfer_interface.v1.certificate_transfer import ( CertificateTransferRequires, ) +from charms.data_platform_libs.v0.s3 import S3Requirer from ops.charm import CharmBase, CollectStatusEvent from ops.framework import StoredState from ops.charm import InstallEvent, RelationJoinedEvent, RelationDepartedEvent @@ -51,6 +52,7 @@ def __init__(self, *args): last_bind_addresses=[], tracing_endpoints={}, ca_cert=None, + s3_credentials=dict(), ) # TODO (manadart 2024-03-05): Get these at need. @@ -59,6 +61,9 @@ def __init__(self, *args): socket_path=self.METRICS_SOCKET_PATH) self._config_change_socket = configchangesocket.ConfigChangeSocketClient( socket_path=self.CONFIG_SOCKET_PATH) + self._s3 = S3Requirer(self, "s3") + + self._observe() self._observe() @@ -89,6 +94,10 @@ def _observe(self): ) self.framework.observe( self._certificate_transfer.on.certificates_removed, self._on_receive_ca_cert_removed) + self.framework.observe( + self._s3.on.credentials_changed, self._on_s3_credentials_changed) + self.framework.observe( + self._s3.on.credentials_gone, self._on_s3_credentials_gone) def _on_install(self, event: InstallEvent): """Ensure that the controller configuration file exists.""" @@ -355,6 +364,16 @@ def _update_charm_tracing_config(self): ca_cert=self._stored.ca_cert, ) + def _on_s3_credentials_changed(self, _event): + """Handle new or updated S3 credentials.""" + credentials = self._s3.get_s3_connection_info() + self._stored.s3_credentials = credentials + logger.info("received S3 credentials update for bucket %s", credentials.get("bucket")) + + def _on_s3_credentials_gone(self, _event): + """Handle removal of S3 credentials.""" + self._stored.s3_credentials = dict() + logger.info("S3 credentials removed") def metrics_username(relation: Relation) -> str: """ diff --git a/tests/test_charm.py b/tests/test_charm.py index a612083..3b88fab 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -475,6 +475,51 @@ def test_dbcluster_relation_departed( harness.evaluate_status() self.assertIsInstance(harness.charm.unit.status, ActiveStatus) + def test_s3_relation_credentials_changed(self): + harness = self.harness + harness.set_leader(True) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + harness.update_relation_data( + relation_id, + "s3-integrator", + { + "access-key": "ak", + "secret-key": "sk", + "bucket": "test-bucket", + "endpoint": "https://s3.example", + }, + ) + + self.assertEqual( + harness.charm._stored.s3_credentials, + { + "access-key": "ak", + "secret-key": "sk", + "bucket": "test-bucket", + "endpoint": "https://s3.example", + }, + ) + + def test_s3_relation_credentials_gone(self): + harness = self.harness + harness.set_leader(True) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + harness.update_relation_data( + relation_id, + "s3-integrator", + {"access-key": "ak", "secret-key": "sk"}, + ) + self.assertIn("access-key", harness.charm._stored.s3_credentials) + + harness.remove_relation(relation_id) + self.assertEqual(harness.charm._stored.s3_credentials, {}) + class mockNetwork: def __init__(self, addresses): From 974b3aa7f9835d0cf934c378d0fff568838b7ea6 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Fri, 6 Mar 2026 16:03:17 +0000 Subject: [PATCH 03/11] feat: make requests to control socket Ensure we call adding and removing s3 credentials when a relation is added or removed. --- src/charm.py | 4 ++-- src/controlsocket.py | 15 ++++++++++++++ tests/test_charm.py | 48 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/charm.py b/src/charm.py index 56cc4f1..a632b4f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -368,12 +368,12 @@ def _on_s3_credentials_changed(self, _event): """Handle new or updated S3 credentials.""" credentials = self._s3.get_s3_connection_info() self._stored.s3_credentials = credentials - logger.info("received S3 credentials update for bucket %s", credentials.get("bucket")) + self._control_socket.add_s3_credentials(credentials) def _on_s3_credentials_gone(self, _event): """Handle removal of S3 credentials.""" + self._control_socket.remove_s3_credentials() self._stored.s3_credentials = dict() - logger.info("S3 credentials removed") def metrics_username(relation: Relation) -> str: """ diff --git a/src/controlsocket.py b/src/controlsocket.py index 423a5e0..f2ac219 100644 --- a/src/controlsocket.py +++ b/src/controlsocket.py @@ -51,3 +51,18 @@ def set_charm_tracing_config( body=body, ) logger.debug('result of set_charm_tracing_config request: %r', resp) + + def add_s3_credentials(self, credentials: dict): + resp = self.json_request( + method='POST', + path='/s3-credentials', + body=credentials, + ) + logger.debug('result of add_s3_credentials request: %r', resp) + + def remove_s3_credentials(self): + resp = self.json_request( + method='DELETE', + path='/s3-credentials', + ) + logger.debug('result of remove_s3_credentials request: %r', resp) diff --git a/tests/test_charm.py b/tests/test_charm.py index 3b88fab..daa68f6 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -475,7 +475,8 @@ def test_dbcluster_relation_departed( harness.evaluate_status() self.assertIsInstance(harness.charm.unit.status, ActiveStatus) - def test_s3_relation_credentials_changed(self): + @patch("controlsocket.ControlSocketClient.add_s3_credentials") + def test_s3_relation_credentials_changed(self, mock_add_s3_credentials): harness = self.harness harness.set_leader(True) @@ -502,8 +503,51 @@ def test_s3_relation_credentials_changed(self): "endpoint": "https://s3.example", }, ) + mock_add_s3_credentials.assert_called_once_with(harness.charm._stored.s3_credentials) - def test_s3_relation_credentials_gone(self): + @patch("controlsocket.ControlSocketClient.add_s3_credentials") + def test_s3_relation_credentials_updated(self, mock_add_s3_credentials): + harness = self.harness + harness.set_leader(True) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + harness.update_relation_data( + relation_id, + "s3-integrator", + {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, + ) + self.assertEqual(harness.charm._stored.s3_credentials["access-key"], "ak") + + harness.update_relation_data( + relation_id, + "s3-integrator", + {"access-key": "ak2", "secret-key": "sk2", "bucket": "test-bucket"}, + ) + self.assertEqual( + harness.charm._stored.s3_credentials, + {"access-key": "ak2", "secret-key": "sk2", "bucket": "test-bucket"}, + ) + mock_add_s3_credentials.assert_called_with( + {"access-key": "ak2", "secret-key": "sk2", "bucket": "test-bucket"} + ) + + def test_s3_relation_sets_bucket_on_join(self): + harness = self.harness + harness.set_leader(True) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + # Bucket is auto-set by the S3Requirer when bucket_name is not provided. + data = harness.get_relation_data(relation_id, harness.charm.app.name) + self.assertEqual(data["bucket"], f"relation-{relation_id}") + self.assertEqual(harness.charm._stored.s3_credentials, {}) + + @patch("controlsocket.ControlSocketClient.remove_s3_credentials") + @patch("controlsocket.ControlSocketClient.add_s3_credentials") + def test_s3_relation_credentials_gone(self, *_): harness = self.harness harness.set_leader(True) From bafed301cf6a7f947a56456aeff53c05ce7d253a Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Sat, 7 Mar 2026 20:58:30 +0000 Subject: [PATCH 04/11] feat: only set credential if leader --- src/charm.py | 6 ++++-- tests/test_charm.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index a632b4f..f799eb6 100755 --- a/src/charm.py +++ b/src/charm.py @@ -368,11 +368,13 @@ def _on_s3_credentials_changed(self, _event): """Handle new or updated S3 credentials.""" credentials = self._s3.get_s3_connection_info() self._stored.s3_credentials = credentials - self._control_socket.add_s3_credentials(credentials) + if self.unit.is_leader(): + self._control_socket.add_s3_credentials(credentials) def _on_s3_credentials_gone(self, _event): """Handle removal of S3 credentials.""" - self._control_socket.remove_s3_credentials() + if self.unit.is_leader(): + self._control_socket.remove_s3_credentials() self._stored.s3_credentials = dict() def metrics_username(relation: Relation) -> str: diff --git a/tests/test_charm.py b/tests/test_charm.py index daa68f6..2e5d5b1 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -505,6 +505,26 @@ def test_s3_relation_credentials_changed(self, mock_add_s3_credentials): ) mock_add_s3_credentials.assert_called_once_with(harness.charm._stored.s3_credentials) + @patch("controlsocket.ControlSocketClient.add_s3_credentials") + def test_s3_relation_credentials_changed_non_leader_no_set(self, mock_add_s3_credentials): + harness = self.harness + harness.set_leader(False) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + harness.update_relation_data( + relation_id, + "s3-integrator", + {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, + ) + + self.assertEqual( + harness.charm._stored.s3_credentials, + {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, + ) + mock_add_s3_credentials.assert_not_called() + @patch("controlsocket.ControlSocketClient.add_s3_credentials") def test_s3_relation_credentials_updated(self, mock_add_s3_credentials): harness = self.harness @@ -564,6 +584,24 @@ def test_s3_relation_credentials_gone(self, *_): harness.remove_relation(relation_id) self.assertEqual(harness.charm._stored.s3_credentials, {}) + @patch("controlsocket.ControlSocketClient.remove_s3_credentials") + def test_s3_relation_credentials_gone_non_leader(self, mock_remove_s3_credentials): + harness = self.harness + harness.set_leader(False) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + harness.update_relation_data( + relation_id, + "s3-integrator", + {"access-key": "ak", "secret-key": "sk"}, + ) + harness.remove_relation(relation_id) + + self.assertEqual(harness.charm._stored.s3_credentials, {}) + mock_remove_s3_credentials.assert_not_called() + class mockNetwork: def __init__(self, addresses): From a78b2375abd5556ecd96b8ddfb1b2c8a56d43f8f Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Sat, 7 Mar 2026 21:06:35 +0000 Subject: [PATCH 05/11] feat: ensure we handle errors when setting s3 creds --- src/charm.py | 12 +++++++++-- tests/test_charm.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index f799eb6..9c45faf 100755 --- a/src/charm.py +++ b/src/charm.py @@ -369,12 +369,20 @@ def _on_s3_credentials_changed(self, _event): credentials = self._s3.get_s3_connection_info() self._stored.s3_credentials = credentials if self.unit.is_leader(): - self._control_socket.add_s3_credentials(credentials) + try: + self._control_socket.add_s3_credentials(credentials) + except Exception as exc: # pragma: no cover - defensive + logger.error("failed to apply S3 credentials: %s", exc) + self.unit.status = BlockedStatus("failed to apply s3 credentials") def _on_s3_credentials_gone(self, _event): """Handle removal of S3 credentials.""" if self.unit.is_leader(): - self._control_socket.remove_s3_credentials() + try: + self._control_socket.remove_s3_credentials() + except Exception as exc: # pragma: no cover - defensive + logger.error("failed to remove S3 credentials: %s", exc) + self.unit.status = BlockedStatus("failed to remove s3 credentials") self._stored.s3_credentials = dict() def metrics_username(relation: Relation) -> str: diff --git a/tests/test_charm.py b/tests/test_charm.py index 2e5d5b1..cd4dae5 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -505,6 +505,33 @@ def test_s3_relation_credentials_changed(self, mock_add_s3_credentials): ) mock_add_s3_credentials.assert_called_once_with(harness.charm._stored.s3_credentials) + @patch( + "controlsocket.ControlSocketClient.add_s3_credentials", + side_effect=RuntimeError("boom"), + ) + def test_s3_relation_credentials_changed_failure_sets_blocked(self, _mock_add): + harness = self.harness + harness.set_leader(True) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + harness.update_relation_data( + relation_id, + "s3-integrator", + {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, + ) + + self.assertEqual( + harness.charm._stored.s3_credentials, + {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, + ) + self.assertIsInstance(harness.charm.unit.status, BlockedStatus) + self.assertIn( + "failed to apply s3 credentials", + harness.charm.unit.status.message, + ) + @patch("controlsocket.ControlSocketClient.add_s3_credentials") def test_s3_relation_credentials_changed_non_leader_no_set(self, mock_add_s3_credentials): harness = self.harness @@ -602,6 +629,29 @@ def test_s3_relation_credentials_gone_non_leader(self, mock_remove_s3_credential self.assertEqual(harness.charm._stored.s3_credentials, {}) mock_remove_s3_credentials.assert_not_called() + @patch( + "controlsocket.ControlSocketClient.remove_s3_credentials", + side_effect=RuntimeError("boom"), + ) + def test_s3_relation_credentials_gone_failure_sets_blocked(self, _mock_remove): + harness = self.harness + harness.set_leader(True) + + relation_id = harness.add_relation("s3", "s3-integrator") + harness.add_relation_unit(relation_id, "s3-integrator/0") + + harness.update_relation_data( + relation_id, + "s3-integrator", + {"access-key": "ak", "secret-key": "sk"}, + ) + + harness.remove_relation(relation_id) + + self.assertEqual(harness.charm._stored.s3_credentials, {}) + self.assertIsInstance(harness.charm.unit.status, BlockedStatus) + self.assertIn("failed to remove s3 credentials", harness.charm.unit.status.message) + class mockNetwork: def __init__(self, addresses): From fff7f96dcefeee957ab37b2158a031d127af0f56 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Sat, 7 Mar 2026 21:08:28 +0000 Subject: [PATCH 06/11] feat: go into maintenance mode when adding creds --- src/charm.py | 3 ++- tests/test_charm.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index 9c45faf..8adea9f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -20,7 +20,7 @@ from ops.framework import StoredState from ops.charm import InstallEvent, RelationJoinedEvent, RelationDepartedEvent from ops.main import main -from ops.model import ActiveStatus, BlockedStatus, Relation +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, Relation from pathlib import Path from typing import List @@ -371,6 +371,7 @@ def _on_s3_credentials_changed(self, _event): if self.unit.is_leader(): try: self._control_socket.add_s3_credentials(credentials) + self.unit.status = MaintenanceStatus("applying s3 credentials") except Exception as exc: # pragma: no cover - defensive logger.error("failed to apply S3 credentials: %s", exc) self.unit.status = BlockedStatus("failed to apply s3 credentials") diff --git a/tests/test_charm.py b/tests/test_charm.py index cd4dae5..8005bb3 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -18,7 +18,7 @@ TransportProtocolType, ) from charm import JujuControllerCharm, AgentConfException -from ops.model import BlockedStatus, ActiveStatus +from ops.model import BlockedStatus, ActiveStatus, MaintenanceStatus from ops.testing import Harness from unittest.mock import mock_open, patch from unixsocket import ConnectionError as SocketConnectionError @@ -504,6 +504,7 @@ def test_s3_relation_credentials_changed(self, mock_add_s3_credentials): }, ) mock_add_s3_credentials.assert_called_once_with(harness.charm._stored.s3_credentials) + self.assertIsInstance(harness.charm.unit.status, MaintenanceStatus) @patch( "controlsocket.ControlSocketClient.add_s3_credentials", @@ -579,6 +580,7 @@ def test_s3_relation_credentials_updated(self, mock_add_s3_credentials): mock_add_s3_credentials.assert_called_with( {"access-key": "ak2", "secret-key": "sk2", "bucket": "test-bucket"} ) + self.assertIsInstance(harness.charm.unit.status, MaintenanceStatus) def test_s3_relation_sets_bucket_on_join(self): harness = self.harness From 6c1f61b2f7edec34157c929868aef081d3571588 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Sat, 7 Mar 2026 21:34:41 +0000 Subject: [PATCH 07/11] feat: only handle creds if leader --- src/charm.py | 5 +++-- tests/test_charm.py | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/charm.py b/src/charm.py index 8adea9f..5e67629 100755 --- a/src/charm.py +++ b/src/charm.py @@ -366,9 +366,10 @@ def _update_charm_tracing_config(self): def _on_s3_credentials_changed(self, _event): """Handle new or updated S3 credentials.""" - credentials = self._s3.get_s3_connection_info() - self._stored.s3_credentials = credentials if self.unit.is_leader(): + credentials = self._s3.get_s3_connection_info() + self._stored.s3_credentials = credentials + try: self._control_socket.add_s3_credentials(credentials) self.unit.status = MaintenanceStatus("applying s3 credentials") diff --git a/tests/test_charm.py b/tests/test_charm.py index 8005bb3..f2f2a35 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -547,10 +547,7 @@ def test_s3_relation_credentials_changed_non_leader_no_set(self, mock_add_s3_cre {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, ) - self.assertEqual( - harness.charm._stored.s3_credentials, - {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, - ) + self.assertEqual(harness.charm._stored.s3_credentials, {}) mock_add_s3_credentials.assert_not_called() @patch("controlsocket.ControlSocketClient.add_s3_credentials") From cab507fe022ec2ce419fe1c3ac406d6ff7e01ebf Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Mon, 9 Mar 2026 20:54:22 +0000 Subject: [PATCH 08/11] feat: ensure active status --- src/charm.py | 1 + tests/test_charm.py | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/charm.py b/src/charm.py index 5e67629..61e8c9b 100755 --- a/src/charm.py +++ b/src/charm.py @@ -70,6 +70,7 @@ def __init__(self, *args): def _observe(self): """Set up all framework event observers.""" self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.on.start, self._on_start) self.framework.observe(self.on.collect_unit_status, self._on_collect_status) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe( diff --git a/tests/test_charm.py b/tests/test_charm.py index f2f2a35..c1773ec 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -77,6 +77,13 @@ def setUp(self): self.addCleanup(self.harness.cleanup) self.harness.begin() + def test_start_sets_active_status(self): + harness = Harness(JujuControllerCharm) + self.addCleanup(harness.cleanup) + harness.begin() + harness.charm.on.start.emit() + self.assertIsInstance(harness.charm.unit.status, ActiveStatus) + def test_dashboard_relation_joined(self): harness = self.harness From 0704fe41f87a747c7c01f7e8046ac976a4ca84c5 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Tue, 10 Mar 2026 11:38:05 +0000 Subject: [PATCH 09/11] feat: use event for information Using the event reveals all the information required, no need to ask for the information from somewhere else. --- src/charm.py | 13 +++++++++---- tests/test_charm.py | 33 ++++++++++++++++++++------------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/charm.py b/src/charm.py index 61e8c9b..b4cffce 100755 --- a/src/charm.py +++ b/src/charm.py @@ -15,7 +15,7 @@ from charms.certificate_transfer_interface.v1.certificate_transfer import ( CertificateTransferRequires, ) -from charms.data_platform_libs.v0.s3 import S3Requirer +from charms.data_platform_libs.v0.s3 import CredentialsChangedEvent, S3Requirer from ops.charm import CharmBase, CollectStatusEvent from ops.framework import StoredState from ops.charm import InstallEvent, RelationJoinedEvent, RelationDepartedEvent @@ -365,13 +365,18 @@ def _update_charm_tracing_config(self): ca_cert=self._stored.ca_cert, ) - def _on_s3_credentials_changed(self, _event): + def _on_s3_credentials_changed(self, event: CredentialsChangedEvent): """Handle new or updated S3 credentials.""" if self.unit.is_leader(): - credentials = self._s3.get_s3_connection_info() + credentials = { + 'access_key': event.access_key, + 'secret_key': event.secret_key, + 'endpoint': event.endpoint, + } self._stored.s3_credentials = credentials try: + logger.info("applying new S3 credentials") self._control_socket.add_s3_credentials(credentials) self.unit.status = MaintenanceStatus("applying s3 credentials") except Exception as exc: # pragma: no cover - defensive @@ -383,10 +388,10 @@ def _on_s3_credentials_gone(self, _event): if self.unit.is_leader(): try: self._control_socket.remove_s3_credentials() + self._stored.s3_credentials = dict() except Exception as exc: # pragma: no cover - defensive logger.error("failed to remove S3 credentials: %s", exc) self.unit.status = BlockedStatus("failed to remove s3 credentials") - self._stored.s3_credentials = dict() def metrics_username(relation: Relation) -> str: """ diff --git a/tests/test_charm.py b/tests/test_charm.py index c1773ec..d963d15 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -501,16 +501,16 @@ def test_s3_relation_credentials_changed(self, mock_add_s3_credentials): }, ) + expected_credentials = { + "access_key": "ak", + "secret_key": "sk", + "endpoint": "https://s3.example", + } self.assertEqual( harness.charm._stored.s3_credentials, - { - "access-key": "ak", - "secret-key": "sk", - "bucket": "test-bucket", - "endpoint": "https://s3.example", - }, + expected_credentials, ) - mock_add_s3_credentials.assert_called_once_with(harness.charm._stored.s3_credentials) + mock_add_s3_credentials.assert_called_once_with(expected_credentials) self.assertIsInstance(harness.charm.unit.status, MaintenanceStatus) @patch( @@ -532,7 +532,7 @@ def test_s3_relation_credentials_changed_failure_sets_blocked(self, _mock_add): self.assertEqual( harness.charm._stored.s3_credentials, - {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, + {"access_key": "ak", "secret_key": "sk", "endpoint": None}, ) self.assertIsInstance(harness.charm.unit.status, BlockedStatus) self.assertIn( @@ -570,7 +570,10 @@ def test_s3_relation_credentials_updated(self, mock_add_s3_credentials): "s3-integrator", {"access-key": "ak", "secret-key": "sk", "bucket": "test-bucket"}, ) - self.assertEqual(harness.charm._stored.s3_credentials["access-key"], "ak") + self.assertEqual( + harness.charm._stored.s3_credentials, + {"access_key": "ak", "secret_key": "sk", "endpoint": None}, + ) harness.update_relation_data( relation_id, @@ -579,10 +582,10 @@ def test_s3_relation_credentials_updated(self, mock_add_s3_credentials): ) self.assertEqual( harness.charm._stored.s3_credentials, - {"access-key": "ak2", "secret-key": "sk2", "bucket": "test-bucket"}, + {"access_key": "ak2", "secret_key": "sk2", "endpoint": None}, ) mock_add_s3_credentials.assert_called_with( - {"access-key": "ak2", "secret-key": "sk2", "bucket": "test-bucket"} + {"access_key": "ak2", "secret_key": "sk2", "endpoint": None} ) self.assertIsInstance(harness.charm.unit.status, MaintenanceStatus) @@ -612,7 +615,8 @@ def test_s3_relation_credentials_gone(self, *_): "s3-integrator", {"access-key": "ak", "secret-key": "sk"}, ) - self.assertIn("access-key", harness.charm._stored.s3_credentials) + expected_credentials = {"access_key": "ak", "secret_key": "sk", "endpoint": None} + self.assertEqual(harness.charm._stored.s3_credentials, expected_credentials) harness.remove_relation(relation_id) self.assertEqual(harness.charm._stored.s3_credentials, {}) @@ -654,7 +658,10 @@ def test_s3_relation_credentials_gone_failure_sets_blocked(self, _mock_remove): harness.remove_relation(relation_id) - self.assertEqual(harness.charm._stored.s3_credentials, {}) + self.assertEqual( + harness.charm._stored.s3_credentials, + {"access_key": "ak", "secret_key": "sk", "endpoint": None}, + ) self.assertIsInstance(harness.charm.unit.status, BlockedStatus) self.assertIn("failed to remove s3 credentials", harness.charm.unit.status.message) From 8a2ac876fbf0d32018e208657f5ae695d8e3dfc7 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Wed, 11 Mar 2026 12:08:46 +0000 Subject: [PATCH 10/11] feat: charmcraft fetch-libs to keep libs insync --- charmcraft.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/charmcraft.yaml b/charmcraft.yaml index 661842c..15962f2 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -59,4 +59,6 @@ charm-libs: - lib: tempo_coordinator_k8s.tracing version: "0" - lib: certificate_transfer_interface.certificate_transfer - version: "1" \ No newline at end of file + version: "1" + - lib: data_platform_libs.s3 + version: "0" From eed10fdf1e78910bdc9f4cbfff0d2873a462a5b4 Mon Sep 17 00:00:00 2001 From: Simon Richardson Date: Wed, 6 May 2026 17:27:16 +0100 Subject: [PATCH 11/11] fix: ensure we're the leader for tracing --- src/charm.py | 38 ++++++++++++++++++++++---------------- tests/test_charm.py | 37 ++++++++++++++++++++++--------------- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/charm.py b/src/charm.py index b4cffce..bde41ad 100755 --- a/src/charm.py +++ b/src/charm.py @@ -61,9 +61,7 @@ def __init__(self, *args): socket_path=self.METRICS_SOCKET_PATH) self._config_change_socket = configchangesocket.ConfigChangeSocketClient( socket_path=self.CONFIG_SOCKET_PATH) - self._s3 = S3Requirer(self, "s3") - - self._observe() + self._s3 = S3Requirer(self, "s3-backend") self._observe() @@ -351,19 +349,26 @@ def _request_config_reload(self): def _update_charm_tracing_config(self): """Update charm configuration with current tracing endpoint and CA cert information.""" - self._control_socket.set_charm_tracing_config( - grpc_endpoint=( - self._stored.tracing_endpoints["otlp_grpc"] - if "otlp_grpc" in self._stored.tracing_endpoints - else None - ), - http_endpoint=( - self._stored.tracing_endpoints["otlp_http"] - if "otlp_http" in self._stored.tracing_endpoints - else None - ), - ca_cert=self._stored.ca_cert, - ) + if not self.unit.is_leader(): + return + + try: + self._control_socket.set_charm_tracing_config( + grpc_endpoint=( + self._stored.tracing_endpoints["otlp_grpc"] + if "otlp_grpc" in self._stored.tracing_endpoints + else None + ), + http_endpoint=( + self._stored.tracing_endpoints["otlp_http"] + if "otlp_http" in self._stored.tracing_endpoints + else None + ), + ca_cert=self._stored.ca_cert, + ) + except Exception as exc: + logger.error("failed to set charm tracing config: %s", exc) + self.unit.status = BlockedStatus("failed to set charm tracing config") def _on_s3_credentials_changed(self, event: CredentialsChangedEvent): """Handle new or updated S3 credentials.""" @@ -393,6 +398,7 @@ def _on_s3_credentials_gone(self, _event): logger.error("failed to remove S3 credentials: %s", exc) self.unit.status = BlockedStatus("failed to remove s3 credentials") + def metrics_username(relation: Relation) -> str: """ Return the username used to access the metrics endpoint, for the given diff --git a/tests/test_charm.py b/tests/test_charm.py index d963d15..89374e5 100644 --- a/tests/test_charm.py +++ b/tests/test_charm.py @@ -156,6 +156,7 @@ def test_metrics_endpoint_relation(self, mock_remove_user, mock_add_user, @patch("controlsocket.ControlSocketClient.set_charm_tracing_config") def test_tracing_relation_updates_endpoints(self, mock_set_tracing_config, *_): harness = self.harness + harness.set_leader(True) relation_id = harness.add_relation("charm-tracing", "tempo-coordinator") harness.add_relation_unit(relation_id, "tempo-coordinator/0") @@ -193,23 +194,27 @@ def test_tracing_relation_change_ignores_not_ready( "controlsocket.ControlSocketClient.set_charm_tracing_config", side_effect=SocketConnectionError("could not connect to socket"), ) - def test_tracing_relation_update_propagates_socket_error(self, *_): + def test_tracing_relation_update_sets_blocked_on_socket_error(self, *_): harness = self.harness + harness.set_leader(True) relation_id = harness.add_relation("charm-tracing", "tempo-coordinator") harness.add_relation_unit(relation_id, "tempo-coordinator/0") - with self.assertRaisesRegex( - SocketConnectionError, "could not connect to socket" - ): - harness.update_relation_data( - relation_id, "tempo-coordinator", tracing_provider_data() - ) + harness.update_relation_data( + relation_id, "tempo-coordinator", tracing_provider_data() + ) + + self.assertIsInstance(harness.charm.unit.status, BlockedStatus) + self.assertEqual( + harness.charm.unit.status.message, "failed to set charm tracing config" + ) @patch("builtins.open", new_callable=mock_open, read_data=agent_conf) @patch("controlsocket.ControlSocketClient.set_charm_tracing_config") def test_tracing_relation_removed_clears_endpoints(self, mock_set_tracing_config, *_): harness = self.harness + harness.set_leader(True) relation_id = harness.add_relation("charm-tracing", "tempo-coordinator") harness.add_relation_unit(relation_id, "tempo-coordinator/0") @@ -241,6 +246,7 @@ def test_tracing_relation_removed_clears_endpoints(self, mock_set_tracing_config @patch("controlsocket.ControlSocketClient.set_charm_tracing_config") def test_receive_ca_cert_updates_stored_ca_cert(self, mock_set_tracing_config, *_): harness = self.harness + harness.set_leader(True) relation_id = harness.add_relation("charm-tracing-ca-cert", "cert-provider") harness.add_relation_unit(relation_id, "cert-provider/0") @@ -277,6 +283,7 @@ def test_receive_ca_cert_update_ignores_empty_cert_list( @patch("controlsocket.ControlSocketClient.set_charm_tracing_config") def test_receive_ca_cert_removed_clears_stored_ca_cert(self, mock_set_tracing_config, *_): harness = self.harness + harness.set_leader(True) relation_id = harness.add_relation("charm-tracing-ca-cert", "cert-provider") harness.add_relation_unit(relation_id, "cert-provider/0") @@ -487,7 +494,7 @@ def test_s3_relation_credentials_changed(self, mock_add_s3_credentials): harness = self.harness harness.set_leader(True) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") harness.update_relation_data( @@ -521,7 +528,7 @@ def test_s3_relation_credentials_changed_failure_sets_blocked(self, _mock_add): harness = self.harness harness.set_leader(True) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") harness.update_relation_data( @@ -545,7 +552,7 @@ def test_s3_relation_credentials_changed_non_leader_no_set(self, mock_add_s3_cre harness = self.harness harness.set_leader(False) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") harness.update_relation_data( @@ -562,7 +569,7 @@ def test_s3_relation_credentials_updated(self, mock_add_s3_credentials): harness = self.harness harness.set_leader(True) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") harness.update_relation_data( @@ -593,7 +600,7 @@ def test_s3_relation_sets_bucket_on_join(self): harness = self.harness harness.set_leader(True) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") # Bucket is auto-set by the S3Requirer when bucket_name is not provided. @@ -607,7 +614,7 @@ def test_s3_relation_credentials_gone(self, *_): harness = self.harness harness.set_leader(True) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") harness.update_relation_data( @@ -626,7 +633,7 @@ def test_s3_relation_credentials_gone_non_leader(self, mock_remove_s3_credential harness = self.harness harness.set_leader(False) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") harness.update_relation_data( @@ -647,7 +654,7 @@ def test_s3_relation_credentials_gone_failure_sets_blocked(self, _mock_remove): harness = self.harness harness.set_leader(True) - relation_id = harness.add_relation("s3", "s3-integrator") + relation_id = harness.add_relation("s3-backend", "s3-integrator") harness.add_relation_unit(relation_id, "s3-integrator/0") harness.update_relation_data(