diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 1df6b2a1..08012655 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -10,7 +10,7 @@ jobs: with: load-test-enabled: false load-test-run-args: "-e LOAD_TEST_HOST=localhost" - modules: '["test_actions.py", "test_charm.py", "test_saml.py"]' + modules: '["test_actions.py", "test_charm.py", "test_s3.py", "test_saml.py"]' trivy-fs-enabled: true trivy-image-config: "trivy.yaml" zap-before-command: "curl -H \"Host: indico.local\" http://localhost/bootstrap --data-raw 'csrf_token=00000000-0000-0000-0000-000000000000&first_name=admin&last_name=admin&email=admin%40admin.com&username=admin&password=lunarlobster&confirm_password=lunarlobster&affiliation=Canonical'" diff --git a/config.yaml b/config.yaml index d3ab6248..bdbfb0a3 100644 --- a/config.yaml +++ b/config.yaml @@ -34,8 +34,3 @@ options: type: string description: URL through which Indico is accessed by users. default: '' - s3_storage: - type: string - description: Comma separated list of parameters to connect to an S3 bucket as in 's3:bucket=my-indico-test-bucket,access_key=12345,secret_key=topsecret'. Details on the available options can be found at https://github.com/indico/indico-plugins/blob/master/storage_s3/README.md#available-config-options - default: '' - diff --git a/docs/how-to/configure-s3.md b/docs/how-to/configure-s3.md index 36aebd92..038390fb 100644 --- a/docs/how-to/configure-s3.md +++ b/docs/how-to/configure-s3.md @@ -1,7 +1,5 @@ # How to configure S3 -An S3 bucket can be leveraged to serve the static content uploaded to Indico, potentially improving performance. Moreover, it is required when scaling the charm to serve the uploaded files. To configure it to set the appropriate connection parameters in `s3_storage` for your existing bucket `juju config [charm_name] s3_storage=[value]`. +An S3 bucket can be leveraged to serve the static content uploaded to Indico, potentially improving performance. Moreover, it is required when scaling the charm to serve the uploaded files. -The configuration option `s3_storage` accepts a comma separated list of parameters as in 's3:bucket=my-indico-test-bucket,access_key=12345,secret_key=topsecret'. More details can be found [in Indico's storage S3 documentation](https://github.com/indico/indico-plugins/blob/master/storage_s3/README.md#available-config-options). - -For more details on the configuration options and their default values see the [configuration reference](https://charmhub.io/indico/configure). \ No newline at end of file +To configure Indico's S3 integration you'll have to deploy the [S3 Integrator charm](https://charmhub.io/s3-integrator/docs/tutorial-getting-started) and integrate it with Indico by running `juju integrate indico s3-integrator`. 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 00000000..7beb113b --- /dev/null +++ b/lib/charms/data_platform_libs/v0/s3.py @@ -0,0 +1,768 @@ +# 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 = 4 + +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 [reportGeneralTypeIssues] + + 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(f"Updated S3 connection info: {updated_connection_data}") + + @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_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 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[reportGeneralTypeIssues] + + 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(f"Updated S3 credentials: {updated_connection_data}") + + 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]) diff --git a/metadata.yaml b/metadata.yaml index 856efc3f..f3ad5e13 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -64,6 +64,9 @@ requires: redis-cache: interface: redis limit: 1 + s3: + interface: s3 + limit: 1 saml: interface: saml limit: 1 diff --git a/pyproject.toml b/pyproject.toml index 594be544..865954ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ line-length = 99 target-version = ["py38"] [tool.coverage.report] -fail_under = 96 +fail_under = 97 show_missing = true # Linting tools configuration diff --git a/src-docs/charm.py.md b/src-docs/charm.py.md index 5490297b..49135725 100644 --- a/src-docs/charm.py.md +++ b/src-docs/charm.py.md @@ -26,7 +26,7 @@ Charm for Indico on kubernetes. Attrs: on: Redis relation charm events. - + ### function `__init__` diff --git a/src-docs/s3_observer.py.md b/src-docs/s3_observer.py.md new file mode 100644 index 00000000..4cf6f05e --- /dev/null +++ b/src-docs/s3_observer.py.md @@ -0,0 +1,40 @@ + + + + +# module `s3_observer.py` +The S3 agent relation observer. + + + +--- + +## class `S3Observer` +The S3 integrator relation observer. + + + +### function `__init__` + +```python +__init__(charm: CharmBase) +``` + +Initialize the observer and register event handlers. + + + +**Args:** + + - `charm`: The parent charm to attach the observer to. + + +--- + +#### property model + +Shortcut for more simple access the model. + + + + diff --git a/src-docs/state.py.md b/src-docs/state.py.md index fec5ea23..61225967 100644 --- a/src-docs/state.py.md +++ b/src-docs/state.py.md @@ -81,6 +81,38 @@ Instantiate ProxyConfig from juju charm environment. ProxyConfig if proxy configuration is provided, None otherwise. +--- + +## class `S3Config` +S3 configuration. + + + +**Attributes:** + + - `bucket`: the S3 bucket. + - `host`: S3 host. + - `access_key`: S3 access key. + - `secret_key`: S3 secret key. + + + + +--- + + + +### function `get_connection_string` + +```python +get_connection_string() → str +``` + +Retrieve a connection string for this instance. + +Returns: the connection string for this instance. + + --- ## class `SamlConfig` @@ -148,19 +180,21 @@ The Indico operator charm state. - `proxy_config`: Proxy configuration. - `saml_config`: SAML configuration. - `smtp_config`: SMTP configuration. + - `s3_config`: S3 configuration. --- - + ### classmethod `from_charm` ```python from_charm( charm: CharmBase, + s3_relation_data: Optional[Dict[str, str]] = None, saml_relation_data: Optional[SamlRelationData] = None, smtp_relation_data: Optional[SmtpRelationData] = None ) → State @@ -173,6 +207,7 @@ Initialize the state from charm. **Args:** - `charm`: The charm root IndicoOperatorCharm. + - `s3_relation_data`: S3 relation data. - `saml_relation_data`: SAML relation data. - `smtp_relation_data`: SMTP relation data. diff --git a/src/charm.py b/src/charm.py index 293aee68..f235a472 100755 --- a/src/charm.py +++ b/src/charm.py @@ -24,6 +24,7 @@ from ops.pebble import ExecError from database_observer import DatabaseObserver +from s3_observer import S3Observer from saml_observer import SamlObserver from smtp_observer import SmtpObserver from state import CharmConfigInvalidError, ProxyConfig, State @@ -61,11 +62,13 @@ def __init__(self, *args): """ super().__init__(*args) self.database = DatabaseObserver(self) + self.s3 = S3Observer(self) self.smtp = SmtpObserver(self) self.saml = SamlObserver(self) try: self.state = State.from_charm( self, + s3_relation_data=self.s3.s3.get_s3_connection_info(), smtp_relation_data=self.smtp.smtp.get_relation_data(), saml_relation_data=self.saml.saml.get_relation_data(), ) @@ -525,8 +528,10 @@ def _get_indico_env_config(self, container: Container) -> Dict: # Piwik settings can't be configured using the config file for the time being: # https://github.com/indico/indico-plugins/issues/182 - if self.config["s3_storage"]: - env_config["STORAGE_DICT"].update({"s3": self.config["s3_storage"]}) # type:ignore + # S3 available config options: + # https://github.com/indico/indico-plugins/blob/master/storage_s3/README.md#available-config-options + if self.state.s3_config: + env_config["STORAGE_DICT"].update({"s3": self.state.s3_config.get_connection_string()}) env_config["ATTACHMENT_STORAGE"] = "s3" env_config["STORAGE_DICT"] = str(env_config["STORAGE_DICT"]) diff --git a/src/s3_observer.py b/src/s3_observer.py new file mode 100644 index 00000000..16c65a14 --- /dev/null +++ b/src/s3_observer.py @@ -0,0 +1,42 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""The S3 agent relation observer.""" +import logging + +from charms.data_platform_libs.v0.s3 import S3Requirer +from ops.charm import CharmBase +from ops.framework import Object + +logger = logging.getLogger(__name__) + + +class S3Observer(Object): + """The S3 integrator relation observer.""" + + _RELATION_NAME = "s3" + + def __init__(self, charm: CharmBase): + """Initialize the observer and register event handlers. + + Args: + charm: The parent charm to attach the observer to. + """ + super().__init__(charm, "s3-observer") + self._charm = charm + self.s3 = S3Requirer( + self._charm, + relation_name=self._RELATION_NAME, + ) + self.framework.observe(self.s3.on.credentials_changed, self._on_credentials_changed) + self.framework.observe(self.s3.on.credentials_gone, self._on_credentials_gone) + + def _on_credentials_changed(self, _) -> None: + """Handle the credentials changed event.""" + # A config changed is emitted to avoid a huge refactor at this point. + self._charm.on.config_changed.emit() + + def _on_credentials_gone(self, _) -> None: + """Handle the credentials gone event.""" + # A config changed is emitted to avoid a huge refactor at this point. + self._charm.on.config_changed.emit() diff --git a/src/state.py b/src/state.py index 11f90907..5d44685d 100644 --- a/src/state.py +++ b/src/state.py @@ -5,7 +5,7 @@ import dataclasses import logging import os -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import ops from charms.saml_integrator.v0.saml import SamlRelationData @@ -68,6 +68,35 @@ def from_env(cls) -> Optional["ProxyConfig"]: ) +class S3Config(BaseModel): # pylint: disable=too-few-public-methods + """S3 configuration. + + Attributes: + bucket: the S3 bucket. + host: S3 host. + access_key: S3 access key. + secret_key: S3 secret key. + """ + + bucket: str + host: Optional[str] + access_key: str + secret_key: str + + def get_connection_string(self) -> str: + """Retrieve a connection string for this instance. + + Returns: the connection string for this instance. + """ + connection_string = ( + f"s3:bucket={self.bucket},access_key={self.access_key}," + f"secret_key={self.secret_key},proxy=true" + ) + if self.host: + connection_string = f"{connection_string},host={self.host}" + return connection_string + + class SamlEndpoint(BaseModel): # pylint: disable=too-few-public-methods """SAML configuration. @@ -126,17 +155,20 @@ class State: # pylint: disable=too-few-public-methods proxy_config: Proxy configuration. saml_config: SAML configuration. smtp_config: SMTP configuration. + s3_config: S3 configuration. """ proxy_config: Optional[ProxyConfig] saml_config: Optional[SamlConfig] smtp_config: Optional[SmtpConfig] + s3_config: Optional[S3Config] # pylint: disable=unused-argument @classmethod def from_charm( cls, charm: ops.CharmBase, + s3_relation_data: Optional[Dict[str, str]] = None, saml_relation_data: Optional[SamlRelationData] = None, smtp_relation_data: Optional[SmtpRelationData] = None, ) -> "State": @@ -144,6 +176,7 @@ def from_charm( Args: charm: The charm root IndicoOperatorCharm. + s3_relation_data: S3 relation data. saml_relation_data: SAML relation data. smtp_relation_data: SMTP relation data. @@ -184,7 +217,22 @@ def from_charm( if smtp_relation_data else None ) + s3_config = ( + S3Config( + bucket=s3_relation_data["bucket"], + host=s3_relation_data["endpoint"], + access_key=s3_relation_data["access-key"], + secret_key=s3_relation_data["secret-key"], + ) + if s3_relation_data and "access-key" in s3_relation_data + else None + ) except ValidationError as exc: logger.error("Invalid juju model proxy configuration, %s", exc) raise CharmConfigInvalidError("Invalid model proxy configuration.") from exc - return cls(proxy_config=proxy_config, smtp_config=smtp_config, saml_config=saml_config) + return cls( + proxy_config=proxy_config, + smtp_config=smtp_config, + saml_config=saml_config, + s3_config=s3_config, + ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index eaefb6bb..44d4f1e9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,6 +5,7 @@ import asyncio from pathlib import Path +from secrets import token_hex import pytest_asyncio import yaml @@ -133,3 +134,31 @@ async def saml_integrator_fixture(ops_test: OpsTest, app: Application): apps=[saml_integrator.name, app.name], status="active", raise_on_error=False ) yield saml_integrator + + +@pytest_asyncio.fixture(scope="module", name="s3_integrator") +async def s3_integrator_fixture(ops_test: OpsTest, app: Application): + """SAML integrator charm used for integration testing.""" + assert ops_test.model + s3_config = { + "bucket": "some-bucket", + "endpoint": "s3.example.com", + } + s3_integrator = await ops_test.model.deploy( + "s3-integrator", channel="latest/edge", config=s3_config + ) + await ops_test.model.wait_for_idle(apps=[s3_integrator.name], idle_period=5, status="blocked") + params = {"access-key": token_hex(16), "secret-key": token_hex(16)} + # Application actually does have units + action_sync_s3_credentials = await s3_integrator.units[0].run_action( # type: ignore + "sync-s3-credentials", **params + ) + await action_sync_s3_credentials.wait() + await ops_test.model.wait_for_idle( + apps=[s3_integrator.name], status="active", raise_on_error=False + ) + await ops_test.model.add_relation(app.name, s3_integrator.name) + await ops_test.model.wait_for_idle( + apps=[s3_integrator.name, app.name], status="active", raise_on_error=False + ) + yield s3_integrator diff --git a/tests/integration/test_actions.py b/tests/integration/test_actions.py index e6eda7ab..19c4c151 100644 --- a/tests/integration/test_actions.py +++ b/tests/integration/test_actions.py @@ -4,16 +4,17 @@ """Indico charm actions integration tests.""" +from secrets import token_hex + import juju.action import pytest import pytest_asyncio -from ops.model import Application +from ops import Application ADMIN_USER_EMAIL = "sample@email.com" ADMIN_USER_EMAIL_FAIL = "sample2@email.com" -@pytest.mark.abort_on_fail @pytest_asyncio.fixture(scope="module") async def add_admin(app: Application): """ @@ -28,7 +29,7 @@ async def add_admin(app: Application): email = ADMIN_USER_EMAIL email_fail = ADMIN_USER_EMAIL_FAIL # This is a test password - password = "somepassword" # nosec + password = token_hex(16) # Application actually does have units action: juju.action.Action = await app.units[0].run_action( # type: ignore diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 734b9345..926a7608 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -8,7 +8,7 @@ import pytest import requests -from ops.model import ActiveStatus, Application +from ops import ActiveStatus, Application from pytest_operator.plugin import OpsTest from charm import CELERY_PROMEXP_PORT, NGINX_PROMEXP_PORT, STATSD_PROMEXP_PORT diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py new file mode 100644 index 00000000..8b130fc4 --- /dev/null +++ b/tests/integration/test_s3.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Indico S3 integration tests.""" + +import re + +import juju +import pytest +import yaml +from ops import Application +from pytest_operator.plugin import OpsTest + + +@pytest.mark.asyncio +@pytest.mark.abort_on_fail +@pytest.mark.usefixtures("s3_integrator") +async def test_s3(app: Application, s3_integrator: Application, ops_test: OpsTest): + """ + arrange: given charm integrated with S3. + act: do nothing. + assert: the pebble plan matches the S3 values as configured by the integrator. + """ + # Application actually does have units + return_code, stdout, _ = await ops_test.juju( + "ssh", "--container", app.name, app.units[0].name, "pebble", "plan" # type: ignore + ) + assert return_code == 0 + plan = yaml.safe_load(stdout) + indico_env = plan["services"]["indico"]["environment"] + # STORAGE_DICT is a string representation of a Python dict + # pylint: disable=eval-used + storage_config = eval(indico_env["STORAGE_DICT"]) # nosec + # Application actually does have units + action: juju.action.Action = await s3_integrator.units[0].run_action( # type: ignore + "get-s3-connection-info" + ) + await action.wait() + assert action.status == "completed" + # in get-s3-connection-info, access_key and secret_key are redacted + assert re.match( + f"s3:bucket={action.results['bucket']}," + f"access_key=[^=]+,secret_key=[^=]+,proxy=true,host={action.results['endpoint']}", + storage_config["s3"], + ) diff --git a/tests/integration/test_saml.py b/tests/integration/test_saml.py index fe1ada8c..92f5a3df 100644 --- a/tests/integration/test_saml.py +++ b/tests/integration/test_saml.py @@ -12,7 +12,7 @@ import pytest import requests import urllib3.exceptions -from ops.model import Application +from ops import Application from pytest_operator.plugin import OpsTest diff --git a/tests/unit/test_actions.py b/tests/unit/test_actions.py index b3f6ad82..757b4fb7 100644 --- a/tests/unit/test_actions.py +++ b/tests/unit/test_actions.py @@ -68,7 +68,7 @@ def test_add_admin(self, mock_exec): charm: IndicoOperatorCharm = typing.cast(IndicoOperatorCharm, self.harness.charm) email = "sample@email.com" - password = "somepassword" # nosec + password = token_hex(16) event = MagicMock(spec=ActionEvent) event.params = { "email": email, @@ -163,7 +163,7 @@ def mock_exec_side_effect(*args, **kwargs): # pylint: disable=unused-argument charm: IndicoOperatorCharm = typing.cast(IndicoOperatorCharm, self.harness.charm) email = "sample@email.com" - password = "somepassword" # nosec + password = token_hex(16) event = MagicMock(spec=ActionEvent) event.params = { "email": email, diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index a7a5f459..7a0d6352 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -5,6 +5,7 @@ # pylint:disable=duplicate-code,protected-access from ast import literal_eval +from secrets import token_hex from unittest.mock import MagicMock, patch import ops @@ -12,7 +13,7 @@ from ops.testing import Harness from charm import IndicoOperatorCharm -from state import SamlConfig, SamlEndpoint, SmtpConfig +from state import S3Config, SamlConfig, SamlEndpoint, SmtpConfig from tests.unit.test_base import TestBase @@ -152,14 +153,14 @@ def test_indico_pebble_ready_when_secrets_enabled(self, mock_exec, mock_juju_env mock_exec.return_value = MagicMock(wait_output=MagicMock(return_value=("", None))) self.set_up_all_relations() self.harness.set_leader(True) - self.harness.charm.state.smtp_config = SmtpConfig( + smtp_config = SmtpConfig( host="localhost", port=8025, login="user", - password="pass", # nosec + password=token_hex(16), use_tls=False, ) - + self.harness.charm.state.smtp_config = smtp_config self.harness.container_pebble_ready("indico") updated_plan = self.harness.get_container_pebble_plan("indico").to_dict() @@ -187,10 +188,10 @@ def test_indico_pebble_ready_when_secrets_enabled(self, mock_exec, mock_juju_env self.assertEqual("default", updated_plan_env["ATTACHMENT_STORAGE"]) storage_dict = literal_eval(updated_plan_env["STORAGE_DICT"]) self.assertEqual("fs:/srv/indico/archive", storage_dict["default"]) - self.assertEqual("localhost", updated_plan_env["SMTP_SERVER"]) - self.assertEqual(8025, updated_plan_env["SMTP_PORT"]) - self.assertEqual("user", updated_plan_env["SMTP_LOGIN"]) - self.assertEqual("pass", updated_plan_env["SMTP_PASSWORD"]) + self.assertEqual(smtp_config.host, updated_plan_env["SMTP_SERVER"]) + self.assertEqual(smtp_config.port, updated_plan_env["SMTP_PORT"]) + self.assertEqual(smtp_config.login, updated_plan_env["SMTP_LOGIN"]) + self.assertEqual(smtp_config.password, updated_plan_env["SMTP_PASSWORD"]) self.assertFalse(updated_plan_env["SMTP_USE_TLS"]) service = self.harness.model.unit.get_container("indico").get_service("indico") @@ -228,6 +229,13 @@ def test_config_changed(self, mock_exec): # pylint: disable=R0915 certificates=("cert1,", "cert2"), endpoints=saml_endpoints, ) + s3_config = S3Config( + bucket="test-bucket", + host="s3.example.com", + access_key=token_hex(16), + secret_key=token_hex(16), + ) + self.harness.charm.state.s3_config = s3_config self.harness.charm.state.saml_config = saml_config self.harness.update_config( { @@ -239,7 +247,6 @@ def test_config_changed(self, mock_exec): # pylint: disable=R0915 "indico_public_support_email": "public@email.local", "indico_no_reply_email": "noreply@email.local", "site_url": "https://example.local:8080", - "s3_storage": "s3:bucket=test-bucket,access_key=12345,secret_key=topsecret", } ) @@ -258,7 +265,10 @@ def test_config_changed(self, mock_exec): # pylint: disable=R0915 self.assertEqual("s3", updated_plan_env["ATTACHMENT_STORAGE"]) self.assertEqual("fs:/srv/indico/archive", storage_dict["default"]) self.assertEqual( - "s3:bucket=test-bucket,access_key=12345,secret_key=topsecret", + ( + f"s3:bucket={s3_config.bucket},access_key={s3_config.access_key}," + f"secret_key={s3_config.secret_key},proxy=true,host={s3_config.host}" + ), storage_dict["s3"], ) auth_providers = literal_eval(updated_plan_env["INDICO_AUTH_PROVIDERS"]) diff --git a/tests/unit/test_s3_observer.py b/tests/unit/test_s3_observer.py new file mode 100644 index 00000000..15b8d261 --- /dev/null +++ b/tests/unit/test_s3_observer.py @@ -0,0 +1,82 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""S3 observer unit tests.""" + +# pylint: disable=duplicate-code + +from secrets import token_hex + +import ops +from ops.testing import Harness + +from s3_observer import S3Observer +from state import State + +REQUIRER_METADATA = """ +name: observer-charm +requires: + s3: + interface: s3 +""" + + +class ObservedCharm(ops.CharmBase): + """Class for requirer charm testing.""" + + def __init__(self, *args): + """Construct. + + Args: + args: Variable list of positional arguments passed to the parent constructor. + """ + super().__init__(*args) + self.s3 = S3Observer(self) + self.state = State.from_charm(charm=self) + self.events = [] + self.framework.observe(self.on.config_changed, self._record_event) + + def _record_event(self, event: ops.EventBase) -> None: + """Rececord emitted event in the event list. + + Args: + event: event. + """ + self.events.append(event) + + +def test_credentials_changed_emits_config_changed_event_and_updates_charm_state(): + """ + arrange: set up a charm. + act: integrate with S3. + assert: a config change event is emitted. + """ + relation_data = { + "bucket": "some-bucket", + "host": "example.s3", + "access-key": token_hex(16), + "secret-key": token_hex(16), + } + harness = Harness(ObservedCharm, meta=REQUIRER_METADATA) + harness.begin() + harness.add_relation("s3", "s3-integrator", app_data=relation_data) + assert len(harness.charm.events) == 1 + + +def test_credentials_gone_emits_config_changed_event_and_updates_charm_state(): + """ + arrange: set up a charm and a s3 relation. + act: remove the S3 relation. + assert: a config change event is emitted. + """ + relation_data = { + "bucket": "some-bucket", + "host": "example.s3", + "access-key": token_hex(16), + "secret-key": token_hex(16), + } + harness = Harness(ObservedCharm, meta=REQUIRER_METADATA) + harness.begin() + relation_id = harness.add_relation("s3", "s3-integrator", app_data=relation_data) + harness.remove_relation(relation_id) + assert len(harness.charm.events) == 2 diff --git a/tests/unit/test_saml_observer.py b/tests/unit/test_saml_observer.py index de1f2aee..ad9a8f5e 100644 --- a/tests/unit/test_saml_observer.py +++ b/tests/unit/test_saml_observer.py @@ -43,7 +43,7 @@ def _record_event(self, event: ops.EventBase) -> None: self.events.append(event) -def test_saml_related_emits_config_changed_eventand_updates_charm_state(): +def test_saml_related_emits_config_changed_event_and_updates_charm_state(): """ arrange: set up a charm and a saml relation. act: trigger a relation changed event. diff --git a/tests/unit/test_smtp_observer.py b/tests/unit/test_smtp_observer.py index ead4e11a..52b7657b 100644 --- a/tests/unit/test_smtp_observer.py +++ b/tests/unit/test_smtp_observer.py @@ -5,6 +5,8 @@ # pylint: disable=duplicate-code +from secrets import token_hex + import ops from ops.testing import Harness @@ -43,7 +45,7 @@ def _record_event(self, event: ops.EventBase) -> None: self.events.append(event) -def test_smtp_related_emits_config_changed_eventand_updates_charm_state(): +def test_smtp_related_emits_config_changed_event_and_updates_charm_state(): """ arrange: set up a charm and a smtp relation. act: trigger a relation changed event. @@ -53,7 +55,7 @@ def test_smtp_related_emits_config_changed_eventand_updates_charm_state(): "host": "example.smtp", "port": "25", "user": "example_user", - "password": "somepassword", # nosec + "password": token_hex(16), "auth_type": "plain", "transport_security": "tls", "domain": "domain", diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index ae5ae5f0..4916dde7 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -4,9 +4,11 @@ """Unit tests.""" import unittest +from secrets import token_hex import ops import pytest +from charms.saml_integrator.v0.saml import SamlEndpoint, SamlRelationData from charms.smtp_integrator.v0.smtp import AuthType, SmtpRelationData, TransportSecurity import state @@ -35,21 +37,112 @@ def test_config_from_charm_env(proxy_config: state.ProxyConfig): mock_charm = unittest.mock.MagicMock(spec=ops.CharmBase) mock_charm.config = {} + s3_relation_data = { + "bucket": "sample-bucket", + "endpoint": "s3.example.com", + "access-key": token_hex(16), + "secret-key": token_hex(16), + } + saml_endpoints = ( + SamlEndpoint( + name="SingleSignOnService", + url="https://example.com/login", + binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + ), + SamlEndpoint( + name="SingleLogoutService", + url="https://example.com/logout", + binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", + response_url="https://example.com/response", + ), + ) + saml_relation_data = SamlRelationData( + entity_id="entity", + metadata_url="https://example.com/metadata", + certificates=("cert1,", "cert2"), + endpoints=saml_endpoints, + ) smtp_relation_data = SmtpRelationData( host="example.com", port=22, user="user", - password="passwd", # nosec + password=token_hex(16), transport_security=TransportSecurity.NONE, auth_type=AuthType.NONE, ) - config = state.State.from_charm(mock_charm, smtp_relation_data=smtp_relation_data) + config = state.State.from_charm( + mock_charm, + s3_relation_data=s3_relation_data, + saml_relation_data=saml_relation_data, + smtp_relation_data=smtp_relation_data, + ) + assert config.proxy_config, "Valid proxy config should not return None." assert config.proxy_config.http_proxy == proxy_config.http_proxy assert config.proxy_config.https_proxy == proxy_config.https_proxy assert config.proxy_config.no_proxy == proxy_config.no_proxy + assert config.s3_config.bucket == s3_relation_data["bucket"] + assert config.s3_config.host == s3_relation_data["endpoint"] + assert config.s3_config.access_key == s3_relation_data["access-key"] + assert config.s3_config.secret_key == s3_relation_data["secret-key"] + assert config.saml_config.entity_id == saml_relation_data.entity_id + assert config.saml_config.metadata_url == saml_relation_data.metadata_url + assert config.saml_config.certificates == saml_relation_data.certificates + assert config.saml_config.endpoints[0].name == saml_relation_data.endpoints[0].name + assert config.saml_config.endpoints[0].url == saml_relation_data.endpoints[0].url + assert config.saml_config.endpoints[0].binding == saml_relation_data.endpoints[0].binding + assert config.saml_config.endpoints[1].name == saml_relation_data.endpoints[1].name + assert config.saml_config.endpoints[1].url == saml_relation_data.endpoints[1].url + assert config.saml_config.endpoints[1].binding == saml_relation_data.endpoints[1].binding + assert config.saml_config.endpoints[1].response_url == ( + saml_relation_data.endpoints[1].response_url + ) assert config.smtp_config.host == smtp_relation_data.host assert config.smtp_config.port == smtp_relation_data.port assert config.smtp_config.login == smtp_relation_data.user assert config.smtp_config.password == smtp_relation_data.password assert not config.smtp_config.use_tls + + +def test_s3_config_get_connection_string(): + """ + arrange: create an S3Config object. + act: call the get_connection_string method. + assert: the returned value matches the object attributes. + """ + access_key = token_hex(16) + secret_key = token_hex(16) + s3_config = state.S3Config( + bucket="sample-bucket", + host="s3.example.com", + access_key=access_key, + secret_key=secret_key, + ) + + connection_string = s3_config.get_connection_string() + + assert connection_string == ( + f"s3:bucket=sample-bucket,access_key={access_key}," + f"secret_key={secret_key},proxy=true,host=s3.example.com" + ) + + +def test_s3_config_get_connection_string_without_host(): + """ + arrange: create an S3Config object. + act: call the get_connection_string method. + assert: the returned value matches the object attributes. + """ + access_key = token_hex(16) + secret_key = token_hex(16) + s3_config = state.S3Config( + bucket="sample-bucket", + access_key=access_key, + secret_key=secret_key, + ) + + connection_string = s3_config.get_connection_string() + + assert connection_string == ( + f"s3:bucket=sample-bucket,access_key={access_key},secret_key={secret_key},proxy=true" + )