From cd8e60ed3b2132b5b0346ed6e498976531a25b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinclert=20P=C3=A9rez?= Date: Mon, 7 Apr 2025 16:22:08 +0200 Subject: [PATCH] [LDAP] Include GLAuth and TLS charm libs --- .../v0/certificate_transfer.py | 432 +++++++++++++ lib/charms/glauth_k8s/v0/ldap.py | 571 ++++++++++++++++++ .../postgresql_k8s/v0/postgresql_tls.py | 87 ++- metadata.yaml | 7 + pyproject.toml | 2 + src/charm.py | 30 + src/constants.py | 2 + tests/integration/helpers.py | 4 +- tests/integration/test_backups_pitr_aws.py | 4 +- tests/integration/test_backups_pitr_gcp.py | 4 +- tests/integration/test_tls.py | 4 +- tests/unit/test_charm.py | 44 +- 12 files changed, 1174 insertions(+), 17 deletions(-) create mode 100644 lib/charms/certificate_transfer_interface/v0/certificate_transfer.py create mode 100644 lib/charms/glauth_k8s/v0/ldap.py diff --git a/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py b/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py new file mode 100644 index 0000000000..d5c2aa1692 --- /dev/null +++ b/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py @@ -0,0 +1,432 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the certificate_transfer relation. + +This library contains the Requires and Provides classes for handling the +ertificate-transfer interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.certificate_transfer_interface.v0.certificate_transfer +``` + +### Provider charm +The provider charm is the charm providing public certificates to another charm that requires them. + +Example: +```python +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main + +from lib.charms.certificate_transfer_interface.v0.certificate_transfer import( + CertificateTransferProvides, +) + + +class DummyCertificateTransferProviderCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.certificate_transfer = CertificateTransferProvides(self, "certificates") + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent): + certificate = "my certificate" + ca = "my CA certificate" + chain = ["certificate 1", "certificate 2"] + self.certificate_transfer.set_certificate( + certificate=certificate, ca=ca, chain=chain, relation_id=event.relation.id + ) + + +if __name__ == "__main__": + main(DummyCertificateTransferProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. + +Example: +```python + +from ops.charm import CharmBase +from ops.main import main + +from lib.charms.certificate_transfer_interface.v0.certificate_transfer import ( + CertificateAvailableEvent, + CertificateRemovedEvent, + CertificateTransferRequires, +) + + +class DummyCertificateTransferRequirerCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.certificate_transfer = CertificateTransferRequires(self, "certificates") + self.framework.observe( + self.certificate_transfer.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.certificate_transfer.on.certificate_removed, self._on_certificate_removed + ) + + def _on_certificate_available(self, event: CertificateAvailableEvent): + print(event.certificate) + print(event.ca) + print(event.chain) + print(event.relation_id) + + def _on_certificate_removed(self, event: CertificateRemovedEvent): + print(event.relation_id) + + +if __name__ == "__main__": + main(DummyCertificateTransferRequirerCharm) +``` + +You can relate both charms by running: + +```bash +juju relate +``` + +""" + +import json +import logging +from typing import List, Mapping + +from jsonschema import exceptions, validate # type: ignore[import-untyped] +from ops import Relation +from ops.charm import ( + CharmBase, + CharmEvents, + RelationBrokenEvent, + RelationChangedEvent, + RelationCreatedEvent, +) +from ops.framework import EventBase, EventSource, Handle, Object + +# The unique Charmhub library identifier, never change it +LIBID = "3785165b24a743f2b0c60de52db25c8b" + +# 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 = 11 + +PYDEPS = ["jsonschema"] + + +logger = logging.getLogger(__name__) + + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/certificate_transfer/schemas/provider.json", + "type": "object", + "title": "`certificate_transfer` provider schema", + "description": "The `certificate_transfer` root schema comprises the entire provider application databag for this interface.", + "default": {}, + "examples": [ + { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUW42TU9LSjEZLMCclWrvSwAsgRtcwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDMxOVoXDTI0MDMyMzE4NDMxOVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJGUw\nNjVmMWI3LTE2OWEtNDE5YS1iNmQyLTc3OWJkOGM4NzIwNjCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBAK42ixoklDH5K5i1NxXo/AFACDa956pE5RA57wlC\nBfgUYaIDRmv7TUVJh6zoMZSD6wjSZl3QgP7UTTZeHbvs3QE9HUwEkH1Lo3a8vD3z\neqsE2vSnOkpWWnPbfxiQyrTm77/LAWBt7lRLRLdfL6WcucD3wsGqm58sWXM3HG0f\nSN7PHCZUFqU6MpkHw8DiKmht5hBgWG+Vq3Zw8MNaqpwb/NgST3yYdcZwb58G2FTS\nZvDSdUfRmD/mY7TpciYV8EFylXNNFkth8oGNLunR9adgZ+9IunfRKj1a7S5GSwXU\nAZDaojw+8k5i3ikztsWH11wAVCiLj/3euIqq95z8xGycnKcCAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEAWMvcaozgBrZ/MAxzTJmp5gZyLxmMNV6iT9dcqbwzDtDtBvA/\n46ux6ytAQ+A7Bd3AubvozwCr1Id6g66ae0blWYRRZmF8fDdX/SBjIUkv7u9A3NVQ\nXN9gsEvK9pdpfN4ZiflfGSLdhM1STHycLmhG6H5s7HklbukMRhQi+ejbSzm/wiw1\nipcxuKhSUIVNkTLusN5b+HE2gwF1fn0K0z5jWABy08huLgbaEKXJEx5/FKLZGJga\nfpIzAdf25kMTu3gggseaAmzyX3AtT1i8A8nqYfe8fnnVMkvud89kq5jErv/hlMC9\n49g5yWQR2jilYYM3j9BHDuB+Rs+YS5BCep1JnQ==\n-----END CERTIFICATE-----\n", + "ca": "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUdiBwE/CtaBXJl3MArjZen6Y8kigwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDg1OVoXDTI0MDMyMzE4NDg1OVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJDEw\nMDdjNDBhLWUwYzMtNDVlOS05YTAxLTVlYjY0NWQ0ZmEyZDCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANOnUl6JDlXpLMRr/PxgtfE/E5Yk6E/TkPkPL/Kk\ntUGjEi42XZDg9zn3U6cjTDYu+rfKY2jiitfsduW6DQIkEpz3AvbuCMbbgnFpcjsB\nYysLSMTmuz/AVPrfnea/tQTALcONCSy1VhAjGSr81ZRSMB4khl9StSauZrbkpJ1P\nshqkFSUyAi31mKrnXz0Es/v0Yi0FzAlgWrZ4u1Ld+Bo2Xz7oK4mHf7/93Jc+tEaM\nIqG6ocD0q8bjPp0tlSxftVADNUzWlZfM6fue5EXzOsKqyDrxYOSchfU9dNzKsaBX\nkxbHEeSUPJeYYj7aVPEfAs/tlUGsoXQvwWfRie8grp2BoLECAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEACZARBpHYH6Gr2a1ka0mCWfBmOZqfDVan9rsI5TCThoylmaXW\nquEiZ2LObI+5faPzxSBhr9TjJlQamsd4ywout7pHKN8ZGqrCMRJ1jJbUfobu1n2k\nUOsY4+jzV1IRBXJzj64fLal4QhUNv341lAer6Vz3cAyRk7CK89b/DEY0x+jVpyZT\n1osx9JtsOmkDTgvdStGzq5kPKWOfjwHkmKQaZXliCgqbhzcCERppp1s/sX6K7nIh\n4lWiEmzUSD3Hngk51KGWlpZszO5KQ4cSZ3HUt/prg+tt0ROC3pY61k+m5dDUa9M8\nRtMI6iTjzSj/UV8DiAx0yeM+bKoy4jGeXmaL3g==\n-----END CERTIFICATE-----\n", + "chain": [ + "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUW42TU9LSjEZLMCclWrvSwAsgRtcwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDMxOVoXDTI0MDMyMzE4NDMxOVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJGUw\nNjVmMWI3LTE2OWEtNDE5YS1iNmQyLTc3OWJkOGM4NzIwNjCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBAK42ixoklDH5K5i1NxXo/AFACDa956pE5RA57wlC\nBfgUYaIDRmv7TUVJh6zoMZSD6wjSZl3QgP7UTTZeHbvs3QE9HUwEkH1Lo3a8vD3z\neqsE2vSnOkpWWnPbfxiQyrTm77/LAWBt7lRLRLdfL6WcucD3wsGqm58sWXM3HG0f\nSN7PHCZUFqU6MpkHw8DiKmht5hBgWG+Vq3Zw8MNaqpwb/NgST3yYdcZwb58G2FTS\nZvDSdUfRmD/mY7TpciYV8EFylXNNFkth8oGNLunR9adgZ+9IunfRKj1a7S5GSwXU\nAZDaojw+8k5i3ikztsWH11wAVCiLj/3euIqq95z8xGycnKcCAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEAWMvcaozgBrZ/MAxzTJmp5gZyLxmMNV6iT9dcqbwzDtDtBvA/\n46ux6ytAQ+A7Bd3AubvozwCr1Id6g66ae0blWYRRZmF8fDdX/SBjIUkv7u9A3NVQ\nXN9gsEvK9pdpfN4ZiflfGSLdhM1STHycLmhG6H5s7HklbukMRhQi+ejbSzm/wiw1\nipcxuKhSUIVNkTLusN5b+HE2gwF1fn0K0z5jWABy08huLgbaEKXJEx5/FKLZGJga\nfpIzAdf25kMTu3gggseaAmzyX3AtT1i8A8nqYfe8fnnVMkvud89kq5jErv/hlMC9\n49g5yWQR2jilYYM3j9BHDuB+Rs+YS5BCep1JnQ==\n-----END CERTIFICATE-----\n", + "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUdiBwE/CtaBXJl3MArjZen6Y8kigwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDg1OVoXDTI0MDMyMzE4NDg1OVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJDEw\nMDdjNDBhLWUwYzMtNDVlOS05YTAxLTVlYjY0NWQ0ZmEyZDCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANOnUl6JDlXpLMRr/PxgtfE/E5Yk6E/TkPkPL/Kk\ntUGjEi42XZDg9zn3U6cjTDYu+rfKY2jiitfsduW6DQIkEpz3AvbuCMbbgnFpcjsB\nYysLSMTmuz/AVPrfnea/tQTALcONCSy1VhAjGSr81ZRSMB4khl9StSauZrbkpJ1P\nshqkFSUyAi31mKrnXz0Es/v0Yi0FzAlgWrZ4u1Ld+Bo2Xz7oK4mHf7/93Jc+tEaM\nIqG6ocD0q8bjPp0tlSxftVADNUzWlZfM6fue5EXzOsKqyDrxYOSchfU9dNzKsaBX\nkxbHEeSUPJeYYj7aVPEfAs/tlUGsoXQvwWfRie8grp2BoLECAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEACZARBpHYH6Gr2a1ka0mCWfBmOZqfDVan9rsI5TCThoylmaXW\nquEiZ2LObI+5faPzxSBhr9TjJlQamsd4ywout7pHKN8ZGqrCMRJ1jJbUfobu1n2k\nUOsY4+jzV1IRBXJzj64fLal4QhUNv341lAer6Vz3cAyRk7CK89b/DEY0x+jVpyZT\n1osx9JtsOmkDTgvdStGzq5kPKWOfjwHkmKQaZXliCgqbhzcCERppp1s/sX6K7nIh\n4lWiEmzUSD3Hngk51KGWlpZszO5KQ4cSZ3HUt/prg+tt0ROC3pY61k+m5dDUa9M8\nRtMI6iTjzSj/UV8DiAx0yeM+bKoy4jGeXmaL3g==\n-----END CERTIFICATE-----\n", + ], + "version": 0, + } + ], + "properties": { + "certificate": { + "$id": "#/properties/certificate", + "type": "string", + "title": "Public TLS certificate", + "description": "Public TLS certificate", + }, + "ca": { + "$id": "#/properties/ca", + "type": "string", + "title": "CA public TLS certificate", + "description": "CA Public TLS certificate", + }, + "chain": { + "$id": "#/properties/chain", + "type": "array", + "items": {"type": "string", "$id": "#/properties/chain/items"}, + "title": "CA public TLS certificate chain", + "description": "CA public TLS certificate chain", + }, + "version": { + "$id": "#/properties/version", + "type": "integer", + "title": "Interface version", + "minimum": 0, + "description": "Highest supported version of this interface", + }, + }, + "anyOf": [{"required": ["certificate"]}, {"required": ["ca"]}, {"required": ["chain"]}], + "additionalProperties": True, +} + + +class CertificateAvailableEvent(EventBase): + """Charm Event triggered when a TLS certificate is available.""" + + def __init__( + self, + handle: Handle, + certificate: str, + ca: str, + chain: List[str], + relation_id: int, + ): + super().__init__(handle) + self.certificate = certificate + self.ca = ca + self.chain = chain + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Return snapshot.""" + return { + "certificate": self.certificate, + "ca": self.ca, + "chain": self.chain, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + self.relation_id = snapshot["relation_id"] + + +class CertificateRemovedEvent(EventBase): + """Charm Event triggered when a TLS certificate is removed.""" + + def __init__(self, handle: Handle, relation_id: int): + super().__init__(handle) + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Return snapshot.""" + return {"relation_id": self.relation_id} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.relation_id = snapshot["relation_id"] + + +def _load_relation_data(raw_relation_data: Mapping[str, str]) -> dict: + """Load relation data from the relation data bag. + + Args: + raw_relation_data: Relation data from the databag + + Returns: + dict: Relation data in dict format. + """ + loaded_relation_data = {} + for key in raw_relation_data: + try: + loaded_relation_data[key] = json.loads(raw_relation_data[key]) + except (json.decoder.JSONDecodeError, TypeError): + loaded_relation_data[key] = raw_relation_data[key] + return loaded_relation_data + + +class CertificateTransferRequirerCharmEvents(CharmEvents): + """List of events that the Certificate Transfer requirer charm can leverage.""" + + certificate_available = EventSource(CertificateAvailableEvent) + certificate_removed = EventSource(CertificateRemovedEvent) + + +class CertificateTransferProvides(Object): + """Certificate Transfer provider class.""" + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.charm = charm + self.relationship_name = relationship_name + + def set_certificate( + self, + certificate: str, + ca: str, + chain: List[str], + relation_id: int, + ) -> None: + """Add certificates to relation data. + + Args: + certificate (str): Certificate + ca (str): CA Certificate + chain (list): CA Chain + relation_id (int): Juju relation ID + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + raise RuntimeError( + f"No relation found with relation name {self.relationship_name} and " + f"relation ID {relation_id}" + ) + relation.data[self.model.unit]["certificate"] = certificate + relation.data[self.model.unit]["ca"] = ca + relation.data[self.model.unit]["chain"] = json.dumps(chain) + relation.data[self.model.unit]["version"] = str(LIBAPI) + + def remove_certificate(self, relation_id: int) -> None: + """Remove a given certificate from relation data. + + Args: + relation_id (int): Relation ID + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + logger.warning( + "Can't remove certificate - Non-existent relation '%s'", self.relationship_name + ) + return + unit_relation_data = relation.data[self.model.unit] + certificate_removed = False + if "certificate" in unit_relation_data: + relation.data[self.model.unit].pop("certificate") + certificate_removed = True + if "ca" in unit_relation_data: + relation.data[self.model.unit].pop("ca") + certificate_removed = True + if "chain" in unit_relation_data: + relation.data[self.model.unit].pop("chain") + certificate_removed = True + + if certificate_removed: + logger.warning("Certificate removed from relation data") + else: + logger.warning("Can't remove certificate - No certificate in relation data") + + +class CertificateTransferRequires(Object): + """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" + + on = CertificateTransferRequirerCharmEvents() # type: ignore + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + ): + """Generates/use private key and observes relation changed event. + + Args: + charm: Charm object + relationship_name: Juju relation name + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + charm.on[relationship_name].relation_broken, self._on_relation_broken + ) + self.framework.observe( + charm.on[relationship_name].relation_created, self._on_relation_created + ) + + @staticmethod + def _relation_data_is_valid(relation_data: dict) -> bool: + """Return whether relation data is valid based on json schema. + + Args: + relation_data: Relation data in dict format. + + Returns: + bool: Whether relation data is valid. + """ + try: + validate(instance=relation_data, schema=PROVIDER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Emit certificate available event. + + Args: + event: Juju event + + Returns: + None + """ + if not event.unit: + logger.info("No remote unit in relation: %s", self.relationship_name) + return + remote_unit_relation_data = _load_relation_data(event.relation.data[event.unit]) + if not self._relation_data_is_valid(remote_unit_relation_data): + logger.warning( + "Provider relation data did not pass JSON Schema validation: %s", + event.relation.data[event.unit], + ) + return + self.on.certificate_available.emit( + certificate=remote_unit_relation_data.get("certificate"), + ca=remote_unit_relation_data.get("ca"), + chain=remote_unit_relation_data.get("chain"), + relation_id=event.relation.id, + ) + + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handle relation broken event. + + Args: + event: Juju event + + Returns: + None + """ + self.on.certificate_removed.emit(relation_id=event.relation.id) + + def _on_relation_created(self, event: RelationCreatedEvent) -> None: + """Handle relation created event. + + Args: + event: Juju event + + Returns: + None + """ + if self.model.unit.is_leader(): + event.relation.data[self.model.app]["version"] = str(LIBAPI) + + def is_ready(self, relation: Relation) -> bool: + """Check if the relation is ready by checking that it has valid relation data.""" + relation_data = _load_relation_data(relation.data[relation.units.pop()]) + if not self._relation_data_is_valid(relation_data): + logger.warning("Provider relation data did not pass JSON Schema validation: ") + return False + return True diff --git a/lib/charms/glauth_k8s/v0/ldap.py b/lib/charms/glauth_k8s/v0/ldap.py new file mode 100644 index 0000000000..b68e1d2f30 --- /dev/null +++ b/lib/charms/glauth_k8s/v0/ldap.py @@ -0,0 +1,571 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""# Juju Charm Library for the `ldap` Juju Interface. + +This juju charm library contains the Provider and Requirer classes for handling +the `ldap` interface. + +## Requirer Charm + +The requirer charm is expected to: + +- Provide information for the provider charm to deliver LDAP related +information in the juju integration, in order to communicate with the LDAP +server and authenticate LDAP operations +- Listen to the custom juju event `LdapReadyEvent` to obtain the LDAP +related information from the integration +- Listen to the custom juju event `LdapUnavailableEvent` to handle the +situation when the LDAP integration is broken + +```python + +from charms.glauth_k8s.v0.ldap import ( + LdapRequirer, + LdapReadyEvent, + LdapUnavailableEvent, +) + +class RequirerCharm(CharmBase): + # LDAP requirer charm that integrates with an LDAP provider charm. + + def __init__(self, *args): + super().__init__(*args) + + self.ldap_requirer = LdapRequirer(self) + self.framework.observe( + self.ldap_requirer.on.ldap_ready, + self._on_ldap_ready, + ) + self.framework.observe( + self.ldap_requirer.on.ldap_unavailable, + self._on_ldap_unavailable, + ) + + def _on_ldap_ready(self, event: LdapReadyEvent) -> None: + # Consume the LDAP related information + ldap_data = self.ldap_requirer.consume_ldap_relation_data( + relation=event.relation, + ) + + # Configure the LDAP requirer charm + ... + + def _on_ldap_unavailable(self, event: LdapUnavailableEvent) -> None: + # Handle the situation where the LDAP integration is broken + ... +``` + +As shown above, the library offers custom juju events to handle specific +situations, which are listed below: + +- ldap_ready: event emitted when the LDAP related information is ready for +requirer charm to use. +- ldap_unavailable: event emitted when the LDAP integration is broken. + +Additionally, the requirer charmed operator needs to declare the `ldap` +interface in the `metadata.yaml`: + +```yaml +requires: + ldap: + interface: ldap +``` + +## Provider Charm + +The provider charm is expected to: + +- Use the information provided by the requirer charm to provide LDAP related +information for the requirer charm to connect and authenticate to the LDAP +server +- Listen to the custom juju event `LdapRequestedEvent` to offer LDAP related +information in the integration + +```python + +from charms.glauth_k8s.v0.ldap import ( + LdapProvider, + LdapRequestedEvent, +) + +class ProviderCharm(CharmBase): + # LDAP provider charm. + + def __init__(self, *args): + super().__init__(*args) + + self.ldap_provider = LdapProvider(self) + self.framework.observe( + self.ldap_provider.on.ldap_requested, + self._on_ldap_requested, + ) + + def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: + # Consume the information provided by the requirer charm + requirer_data = event.data + + # Prepare the LDAP related information using the requirer's data + ldap_data = ... + + # Update the integration data + self.ldap_provider.update_relations_app_data( + relation.id, + ldap_data, + ) +``` + +As shown above, the library offers custom juju events to handle specific +situations, which are listed below: + +- ldap_requested: event emitted when the requirer charm is requesting the +LDAP related information in order to connect and authenticate to the LDAP server +""" + +import json +from functools import wraps +from string import Template +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union + +import ops +from ops.charm import ( + CharmBase, + RelationBrokenEvent, + RelationChangedEvent, + RelationCreatedEvent, + RelationEvent, +) +from ops.framework import EventSource, Handle, Object, ObjectEvents +from ops.model import Relation, SecretNotFoundError +from pydantic import StrictBool, ValidationError, version + +# The unique CharmHub library identifier, never change it +LIBID = "5a535b3c4d0b40da98e29867128e57b9" + +# 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 = 10 + +PYDEPS = ["pydantic"] + +DEFAULT_RELATION_NAME = "ldap" +BIND_ACCOUNT_SECRET_LABEL_TEMPLATE = Template("relation-$relation_id-bind-account-secret") + +PYDANTIC_IS_V1 = int(version.VERSION.split(".")[0]) < 2 +if PYDANTIC_IS_V1: + # Pydantic v1 backwards compatibility logic, + # see https://docs.pydantic.dev/latest/migration/ for more info. + # This does not offer complete backwards compatibility + + from pydantic import BaseModel as BaseModelV1 + from pydantic import Field as FieldV1 + from pydantic import validator + from pydantic.main import ModelMetaclass + + def Field(*args: Any, **kwargs: Any) -> FieldV1: # noqa N802 + if frozen := kwargs.pop("frozen", None): + kwargs["allow_mutations"] = not frozen + return FieldV1(*args, **kwargs) + + def field_validator(*args: Any, **kwargs: Any) -> Callable: + if kwargs.get("mode") == "before": + kwargs.pop("mode") + kwargs["pre"] = True + return validator(*args, **kwargs) + + encoders_config = {} + + def field_serializer(*fields: str, mode: Optional[str] = None) -> Callable: + def _field_serializer(f: Callable, *args: Any, **kwargs: Any) -> Callable: + @wraps(f) + def wrapper(self: object, *args: Any, **kwargs: Any) -> Any: + return f(self, *args, **kwargs) + + encoders_config[wrapper] = fields + return wrapper + + return _field_serializer + + class ModelCompatibilityMeta(ModelMetaclass): + def __init__(self, name: str, bases: Tuple[object], attrs: Dict) -> None: + if not hasattr(self, "_encoders"): + self._encoders = {} + + self._encoders.update({ + encoder: func + for func in attrs.values() + if callable(func) and func in encoders_config + for encoder in encoders_config[func] + }) + + super().__init__(name, bases, attrs) + + class BaseModel(BaseModelV1, metaclass=ModelCompatibilityMeta): + def model_dump(self, *args: Any, **kwargs: Any) -> Dict: + d = self.dict(*args, **kwargs) + for name, f in self._encoders.items(): + d[name] = f(self, d[name]) + return d + +else: + from pydantic import ( # type: ignore[no-redef] + BaseModel, + Field, + field_serializer, + field_validator, + ) + + +def leader_unit(func: Callable) -> Callable: + @wraps(func) + def wrapper( + obj: Union["LdapProvider", "LdapRequirer"], *args: Any, **kwargs: Any + ) -> Optional[Any]: + if not obj.unit.is_leader(): + return None + + return func(obj, *args, **kwargs) + + return wrapper + + +@leader_unit +def _update_relation_app_databag( + ldap: Union["LdapProvider", "LdapRequirer"], relation: Relation, data: dict +) -> None: + if relation is None: + return + + data = {k: str(v) if v else "" for k, v in data.items()} + relation.data[ldap.app].update(data) + + +class Secret: + def __init__(self, secret: ops.Secret = None) -> None: + self._secret: ops.Secret = secret + + @property + def uri(self) -> str: + return self._secret.id if self._secret else "" + + @classmethod + def load( + cls, + charm: CharmBase, + label: str, + *, + content: Optional[dict[str, str]] = None, + ) -> "Secret": + try: + secret = charm.model.get_secret(label=label) + except SecretNotFoundError: + secret = charm.app.add_secret(label=label, content=content) + + return Secret(secret) + + @classmethod + def create_or_update(cls, charm: CharmBase, label: str, content: dict[str, str]) -> "Secret": + try: + secret = charm.model.get_secret(label=label) + secret.set_content(content=content) + except SecretNotFoundError: + secret = charm.app.add_secret(label=label, content=content) + + return Secret(secret) + + def grant(self, relation: Relation) -> None: + self._secret.grant(relation) + + def remove(self) -> None: + self._secret.remove_all_revisions() + + +class LdapProviderBaseData(BaseModel): + urls: List[str] = Field(frozen=True) + ldaps_urls: List[str] = Field(frozen=True) + base_dn: str = Field(frozen=True) + starttls: StrictBool = Field(frozen=True) + + @field_validator("urls", mode="before") + @classmethod + def validate_ldap_urls(cls, vs: List[str] | str) -> List[str]: + if isinstance(vs, str): + vs = json.loads(vs) + if isinstance(vs, str): + vs = [vs] + + for v in vs: + if not v.startswith("ldap://"): + raise ValidationError.from_exception_data("Invalid LDAP URL scheme.") + + return vs + + @field_validator("ldaps_urls", mode="before") + @classmethod + def validate_ldaps_urls(cls, vs: List[str] | str) -> List[str]: + if isinstance(vs, str): + vs = json.loads(vs) + if isinstance(vs, str): + vs = [vs] + + for v in vs: + if not v.startswith("ldaps://"): + raise ValidationError.from_exception_data("Invalid LDAPS URL scheme.") + + return vs + + @field_serializer("urls", "ldaps_urls") + def serialize_list(self, urls: List[str]) -> str: + return str(json.dumps(urls)) + + @field_validator("starttls", mode="before") + @classmethod + def deserialize_bool(cls, v: str | bool) -> bool: + if isinstance(v, str): + return True if v.casefold() == "true" else False + + return v + + @field_serializer("starttls") + def serialize_bool(self, starttls: bool) -> str: + return str(starttls) + + +class LdapProviderData(LdapProviderBaseData): + bind_dn: str = Field(frozen=True) + bind_password: str = Field(exclude=True) + bind_password_secret: Optional[str] = None + auth_method: Literal["simple"] = Field(frozen=True) + + +class LdapRequirerData(BaseModel): + user: str = Field(frozen=True) + group: str = Field(frozen=True) + + +class LdapRequestedEvent(RelationEvent): + """An event emitted when the LDAP integration is built.""" + + def __init__(self, handle: Handle, relation: Relation) -> None: + super().__init__(handle, relation, relation.app) + + @property + def data(self) -> Optional[LdapRequirerData]: + relation_data = self.relation.data.get(self.relation.app) + return LdapRequirerData(**relation_data) if relation_data else None + + +class LdapProviderEvents(ObjectEvents): + ldap_requested = EventSource(LdapRequestedEvent) + + +class LdapReadyEvent(RelationEvent): + """An event when the LDAP related information is ready.""" + + +class LdapUnavailableEvent(RelationEvent): + """An event when the LDAP integration is unavailable.""" + + +class LdapRequirerEvents(ObjectEvents): + ldap_ready = EventSource(LdapReadyEvent) + ldap_unavailable = EventSource(LdapUnavailableEvent) + + +class LdapProvider(Object): + on = LdapProviderEvents() + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + ) -> None: + super().__init__(charm, relation_name) + + self.charm = charm + self.app = charm.app + self.unit = charm.unit + self._relation_name = relation_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_broken, + self._on_relation_broken, + ) + + @leader_unit + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle the event emitted when the requirer charm provides the necessary data.""" + self.on.ldap_requested.emit(event.relation) + + @leader_unit + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handle the event emitted when the LDAP integration is broken.""" + secret = Secret.load( + self.charm, + label=BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=event.relation.id), + ) + secret.remove() + + def get_bind_password(self, relation_id: int) -> Optional[str]: + """Retrieve the bind account password for a given integration.""" + try: + secret = self.charm.model.get_secret( + label=BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=relation_id) + ) + except SecretNotFoundError: + return None + return secret.get_content().get("password") + + def update_relations_app_data( + self, + data: Union[LdapProviderBaseData, LdapProviderData], + /, + relation_id: Optional[int] = None, + ) -> None: + """An API for the provider charm to provide the LDAP related information.""" + if not (relations := self.charm.model.relations.get(self._relation_name)): + return + + if relation_id is not None and isinstance(data, LdapProviderData): + relations = [relation for relation in relations if relation.id == relation_id] + secret = Secret.create_or_update( + self.charm, + BIND_ACCOUNT_SECRET_LABEL_TEMPLATE.substitute(relation_id=relation_id), + {"password": data.bind_password}, + ) + secret.grant(relations[0]) + data.bind_password_secret = secret.uri + + for relation in relations: + _update_relation_app_databag(self.charm, relation, data.model_dump()) + + +class LdapRequirer(Object): + """An LDAP requirer to consume data delivered by an LDAP provider charm.""" + + on = LdapRequirerEvents() + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + *, + data: Optional[LdapRequirerData] = None, + ) -> None: + super().__init__(charm, relation_name) + + self.charm = charm + self.app = charm.app + self.unit = charm.unit + self._relation_name = relation_name + self._data = data + + self.framework.observe( + self.charm.on[self._relation_name].relation_created, + self._on_ldap_relation_created, + ) + self.framework.observe( + self.charm.on[self._relation_name].relation_changed, + self._on_ldap_relation_changed, + ) + self.framework.observe( + self.charm.on[self._relation_name].relation_broken, + self._on_ldap_relation_broken, + ) + + def _on_ldap_relation_created(self, event: RelationCreatedEvent) -> None: + """Handle the event emitted when an LDAP integration is created.""" + user = self._data.user if self._data else self.app.name + group = self._data.group if self._data else self.model.name + _update_relation_app_databag(self.charm, event.relation, {"user": user, "group": group}) + + def _on_ldap_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle the event emitted when the LDAP related information is ready.""" + provider_app = event.relation.app + + if not event.relation.data.get(provider_app): + return + + self.on.ldap_ready.emit(event.relation) + + def _on_ldap_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handle the event emitted when the LDAP integration is broken.""" + self.on.ldap_unavailable.emit(event.relation) + + def consume_ldap_relation_data( + self, + /, + relation: Optional[Relation] = None, + relation_id: Optional[int] = None, + ) -> Optional[LdapProviderData]: + """An API for the requirer charm to consume the LDAP related information in the application databag.""" + if not relation: + relation = self.charm.model.get_relation(self._relation_name, relation_id) + + if not relation: + return None + + provider_data = dict(relation.data.get(relation.app)) + if secret_id := provider_data.get("bind_password_secret"): + secret = self.charm.model.get_secret(id=secret_id) + provider_data["bind_password"] = secret.get_content().get("password") + return LdapProviderData(**provider_data) if provider_data else None + + def _is_relation_active(self, relation: Relation) -> bool: + """Whether the relation is active based on contained data.""" + try: + _ = repr(relation.data) + return True + except (RuntimeError, ops.ModelError): + return False + + @property + def relations(self) -> List[Relation]: + """The list of Relation instances associated with this relation_name.""" + return [ + relation + for relation in self.charm.model.relations[self._relation_name] + if self._is_relation_active(relation) + ] + + def _ready_for_relation(self, relation: Relation) -> bool: + if not relation.app: + return False + + return "urls" in relation.data[relation.app] and "bind_dn" in relation.data[relation.app] + + def ready(self, relation_id: Optional[int] = None) -> bool: + """Check if the resource has been created. + + This function can be used to check if the Provider answered with data in the charm code + when outside an event callback. + + Args: + relation_id (int, optional): When provided the check is done only for the relation id + provided, otherwise the check is done for all relations + + Returns: + True or False + + Raises: + IndexError: If relation_id is provided but that relation does not exist + """ + if relation_id is None: + return ( + all(self._ready_for_relation(relation) for relation in self.relations) + if self.relations + else False + ) + + try: + relation = [relation for relation in self.relations if relation.id == relation_id][0] + return self._ready_for_relation(relation) + except IndexError: + raise IndexError(f"relation id {relation_id} cannot be accessed") diff --git a/lib/charms/postgresql_k8s/v0/postgresql_tls.py b/lib/charms/postgresql_k8s/v0/postgresql_tls.py index f55543e0cb..2aeaa52af6 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql_tls.py +++ b/lib/charms/postgresql_k8s/v0/postgresql_tls.py @@ -6,8 +6,9 @@ This class handles certificate request and renewal through the interaction with the TLS Certificates Operator. -This library needs that https://charmhub.io/tls-certificates-interface/libraries/tls_certificates -library is imported to work. +This library needs that the following libraries are imported to work: +- https://charmhub.io/certificate-transfer-interface/libraries/certificate_transfer +- https://charmhub.io/tls-certificates-interface/libraries/tls_certificates It also needs the following methods in the charm class: — get_hostname_by_unit: to retrieve the DNS hostname of the unit. @@ -24,6 +25,15 @@ import socket from typing import List, Optional +from charms.certificate_transfer_interface.v0.certificate_transfer import ( + CertificateAvailableEvent as CertificateAddedEvent, +) +from charms.certificate_transfer_interface.v0.certificate_transfer import ( + CertificateRemovedEvent as CertificateRemovedEvent, +) +from charms.certificate_transfer_interface.v0.certificate_transfer import ( + CertificateTransferRequires, +) from charms.tls_certificates_interface.v2.tls_certificates import ( CertificateAvailableEvent, CertificateExpiringEvent, @@ -45,11 +55,12 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version. -LIBPATCH = 13 +LIBPATCH = 14 logger = logging.getLogger(__name__) SCOPE = "unit" -TLS_RELATION = "certificates" +TLS_CREATION_RELATION = "certificates" +TLS_TRANSFER_RELATION = "receive-ca-cert" class PostgreSQLTLS(Object): @@ -63,18 +74,29 @@ def __init__( self.charm = charm self.peer_relation = peer_relation self.additional_dns_names = additional_dns_names or [] - self.certs = TLSCertificatesRequiresV2(self.charm, TLS_RELATION) + self.certs_creation = TLSCertificatesRequiresV2(self.charm, TLS_CREATION_RELATION) + self.certs_transfer = CertificateTransferRequires(self.charm, TLS_TRANSFER_RELATION) self.framework.observe( self.charm.on.set_tls_private_key_action, self._on_set_tls_private_key ) self.framework.observe( - self.charm.on[TLS_RELATION].relation_joined, self._on_tls_relation_joined + self.charm.on[TLS_CREATION_RELATION].relation_joined, self._on_tls_relation_joined + ) + self.framework.observe( + self.charm.on[TLS_CREATION_RELATION].relation_broken, self._on_tls_relation_broken + ) + self.framework.observe( + self.certs_creation.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.certs_creation.on.certificate_expiring, self._on_certificate_expiring ) self.framework.observe( - self.charm.on[TLS_RELATION].relation_broken, self._on_tls_relation_broken + self.certs_transfer.on.certificate_available, self._on_certificate_added + ) + self.framework.observe( + self.certs_transfer.on.certificate_removed, self._on_certificate_removed ) - self.framework.observe(self.certs.on.certificate_available, self._on_certificate_available) - self.framework.observe(self.certs.on.certificate_expiring, self._on_certificate_expiring) def _on_set_tls_private_key(self, event: ActionEvent) -> None: """Set the TLS private key, which will be used for requesting the certificate.""" @@ -93,8 +115,8 @@ def _request_certificate(self, param: Optional[str]): self.charm.set_secret(SCOPE, "key", key.decode("utf-8")) self.charm.set_secret(SCOPE, "csr", csr.decode("utf-8")) - if self.charm.model.get_relation(TLS_RELATION): - self.certs.request_certificate_creation(certificate_signing_request=csr) + if self.charm.model.get_relation(TLS_CREATION_RELATION): + self.certs_creation.request_certificate_creation(certificate_signing_request=csr) @staticmethod def _parse_tls_file(raw_content: str) -> bytes: @@ -117,6 +139,7 @@ def _on_tls_relation_broken(self, event: RelationBrokenEvent) -> None: self.charm.set_secret(SCOPE, "ca", None) self.charm.set_secret(SCOPE, "cert", None) self.charm.set_secret(SCOPE, "chain", None) + if not self.charm.update_config(): logger.debug("Cannot update config at this moment") event.defer() @@ -163,12 +186,52 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: subject=self.charm.get_hostname_by_unit(self.charm.unit.name), **self._get_sans(), ) - self.certs.request_certificate_renewal( + self.certs_creation.request_certificate_renewal( old_certificate_signing_request=old_csr, new_certificate_signing_request=new_csr, ) self.charm.set_secret(SCOPE, "csr", new_csr.decode("utf-8")) + def _on_certificate_added(self, event: CertificateAddedEvent) -> None: + """Enable TLS when TLS certificate is added.""" + relation = self.charm.model.get_relation(TLS_TRANSFER_RELATION, event.relation_id) + if relation is None: + logger.error("Relationship not established anymore.") + return + + secret_name = f"ca-{relation.app.name}" + self.charm.set_secret(SCOPE, secret_name, event.ca) + + try: + if not self.charm.push_ca_file_into_workload(secret_name): + logger.debug("Cannot push TLS certificates at this moment") + event.defer() + return + except (PebbleConnectionError, PathError, ProtocolError, RetryError) as e: + logger.error("Cannot push TLS certificates: %r", e) + event.defer() + return + + def _on_certificate_removed(self, event: CertificateRemovedEvent) -> None: + """Disable TLS when TLS certificate is removed.""" + relation = self.charm.model.get_relation(TLS_TRANSFER_RELATION, event.relation_id) + if relation is None: + logger.error("Relationship not established anymore.") + return + + secret_name = f"ca-{relation.app.name}" + self.charm.set_secret(SCOPE, secret_name, None) + + try: + if not self.charm.clean_ca_file_from_workload(secret_name): + logger.debug("Cannot clean CA certificates at this moment") + event.defer() + return + except (PebbleConnectionError, PathError, ProtocolError, RetryError) as e: + logger.error("Cannot clean CA certificates: %r", e) + event.defer() + return + def _get_sans(self) -> dict: """Create a list of Subject Alternative Names for a PostgreSQL unit. diff --git a/metadata.yaml b/metadata.yaml index 94cb47ec89..61d7ae7533 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -49,10 +49,17 @@ requires: interface: tls-certificates limit: 1 optional: true + receive-ca-cert: + interface: certificate_transfer + optional: true s3-parameters: interface: s3 limit: 1 optional: true + ldap: + interface: ldap + limit: 1 + optional: true tracing: interface: tracing limit: 1 diff --git a/pyproject.toml b/pyproject.toml index c044782906..1a7c1b7d51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ pydantic = "^1.10" cosl = ">=0.0.50" # tls_certificates_interface/v2/tls_certificates.py cryptography = "*" +# certificate_transfer_interface/v0/certificate_transfer.py +# tls_certificates_interface/v2/tls_certificates.py jsonschema = "*" # tempo_coordinator_k8s/v0/charm_tracing.py opentelemetry-exporter-otlp-proto-http = "1.21.0" diff --git a/src/charm.py b/src/charm.py index accd6844be..7fce4c95ee 100755 --- a/src/charm.py +++ b/src/charm.py @@ -95,6 +95,7 @@ TLS_KEY_FILE, TRACING_PROTOCOL, UNIT_SCOPE, + UPDATE_CERTS_BIN_PATH, USER, USER_PASSWORD_KEY, ) @@ -190,6 +191,8 @@ def __init__(self, *args): self.framework.observe(self.on.update_status, self._on_update_status) self.cluster_name = self.app.name self._member_name = self.unit.name.replace("/", "-") + + self._certs_path = "/usr/local/share/ca-certificates" self._storage_path = self.meta.storages["pgdata"].location self.upgrade = PostgreSQLUpgrade( @@ -1797,6 +1800,33 @@ def push_tls_files_to_workload(self) -> bool: logger.exception("TLS files failed to push. Error in config update") return False + def push_ca_file_into_workload(self, secret_name: str) -> bool: + """Move CA certificates file into the PostgreSQL storage path.""" + certs = self.get_secret(UNIT_SCOPE, secret_name) + if certs is not None: + certs_file = Path(self._certs_path, f"{secret_name}.crt") + certs_file.write_text(certs) + subprocess.check_call([UPDATE_CERTS_BIN_PATH]) # noqa: S603 + + try: + return self.update_config() + except Exception: + logger.exception("CA file failed to push. Error in config update") + return False + + def clean_ca_file_from_workload(self, secret_name: str) -> bool: + """Cleans up CA certificates from the PostgreSQL storage path.""" + certs_file = Path(self._certs_path, f"{secret_name}.crt") + certs_file.unlink() + + subprocess.check_call([UPDATE_CERTS_BIN_PATH]) # noqa: S603 + + try: + return self.update_config() + except Exception: + logger.exception("CA file failed to clean. Error in config update") + return False + def _reboot_on_detached_storage(self, event: EventBase) -> None: """Reboot on detached storage. diff --git a/src/constants.py b/src/constants.py index 0fa63efd04..cb0ef1bc20 100644 --- a/src/constants.py +++ b/src/constants.py @@ -56,6 +56,8 @@ POSTGRESQL_DATA_PATH = f"{SNAP_DATA_PATH}/postgresql" POSTGRESQL_LOGS_PATH = f"{SNAP_LOGS_PATH}/postgresql" +UPDATE_CERTS_BIN_PATH = "/usr/sbin/update-ca-certificates" + PGBACKREST_CONFIGURATION_FILE = f"--config={PGBACKREST_CONF_PATH}/pgbackrest.conf" METRICS_PORT = "9187" diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 6fe8fd144f..9794364a4b 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1131,7 +1131,9 @@ async def backup_operations( config={"profile": "testing"}, ) - await ops_test.model.relate(database_app_name, tls_certificates_app_name) + await ops_test.model.relate( + f"{database_app_name}:certificates", f"{tls_certificates_app_name}:certificates" + ) async with ops_test.fast_forward(fast_interval="60s"): await ops_test.model.wait_for_idle(apps=[database_app_name], status="active", timeout=1000) diff --git a/tests/integration/test_backups_pitr_aws.py b/tests/integration/test_backups_pitr_aws.py index d835110179..d291337b7c 100644 --- a/tests/integration/test_backups_pitr_aws.py +++ b/tests/integration/test_backups_pitr_aws.py @@ -68,7 +68,9 @@ async def pitr_backup_operations( logger.info( "integrating self-signed-certificates with postgresql and waiting them to stabilize" ) - await ops_test.model.relate(database_app_name, tls_certificates_app_name) + await ops_test.model.relate( + f"{database_app_name}:certificates", f"{tls_certificates_app_name}:certificates" + ) async with ops_test.fast_forward(fast_interval="60s"): await ops_test.model.wait_for_idle( apps=[database_app_name, tls_certificates_app_name], status="active", timeout=1000 diff --git a/tests/integration/test_backups_pitr_gcp.py b/tests/integration/test_backups_pitr_gcp.py index 40b9e3a41f..99ecb5f72d 100644 --- a/tests/integration/test_backups_pitr_gcp.py +++ b/tests/integration/test_backups_pitr_gcp.py @@ -68,7 +68,9 @@ async def pitr_backup_operations( logger.info( "integrating self-signed-certificates with postgresql and waiting them to stabilize" ) - await ops_test.model.relate(database_app_name, tls_certificates_app_name) + await ops_test.model.relate( + f"{database_app_name}:certificates", f"{tls_certificates_app_name}:certificates" + ) async with ops_test.fast_forward(fast_interval="60s"): await ops_test.model.wait_for_idle( apps=[database_app_name, tls_certificates_app_name], status="active", timeout=1000 diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py index 8a02a03755..f37131907b 100644 --- a/tests/integration/test_tls.py +++ b/tests/integration/test_tls.py @@ -66,7 +66,9 @@ async def test_tls_enabled(ops_test: OpsTest) -> None: ) # Relate it to the PostgreSQL to enable TLS. - await ops_test.model.relate(DATABASE_APP_NAME, tls_certificates_app_name) + await ops_test.model.relate( + f"{DATABASE_APP_NAME}:certificates", f"{tls_certificates_app_name}:certificates" + ) await ops_test.model.wait_for_idle(status="active", timeout=1500, raise_on_error=False) # Wait for all units enabling TLS. diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index e500a8d099..50776ef87d 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -41,7 +41,13 @@ SwitchoverFailedError, SwitchoverNotSyncError, ) -from constants import PEER, POSTGRESQL_SNAP_NAME, SECRET_INTERNAL_LABEL, SNAP_PACKAGES +from constants import ( + PEER, + POSTGRESQL_SNAP_NAME, + SECRET_INTERNAL_LABEL, + SNAP_PACKAGES, + UPDATE_CERTS_BIN_PATH, +) CREATE_CLUSTER_CONF_PATH = "/etc/postgresql-common/createcluster.d/pgcharm.conf" @@ -1766,6 +1772,42 @@ def test_push_tls_files_to_workload(harness): assert _render_file.call_count == 2 +def test_push_ca_file_into_workload(harness): + with ( + patch("charm.PostgresqlOperatorCharm.update_config") as _update_config, + patch("pathlib.Path.write_text") as _write_text, + patch("subprocess.check_call") as _check_call, + ): + harness.charm.set_secret("unit", "ca-app", "test-ca") + + assert harness.charm.push_ca_file_into_workload("ca-app") + _write_text.assert_called_once() + _check_call.assert_called_once_with([UPDATE_CERTS_BIN_PATH]) + _update_config.assert_called_once() + + +def test_clean_ca_file_from_workload(harness): + with ( + patch("charm.PostgresqlOperatorCharm.update_config") as _update_config, + patch("pathlib.Path.write_text") as _write_text, + patch("pathlib.Path.unlink") as _unlink, + patch("subprocess.check_call") as _check_call, + ): + harness.charm.set_secret("unit", "ca-app", "test-ca") + + assert harness.charm.push_ca_file_into_workload("ca-app") + _write_text.assert_called_once() + _check_call.assert_called_once_with([UPDATE_CERTS_BIN_PATH]) + _update_config.assert_called_once() + + _check_call.reset_mock() + _update_config.reset_mock() + + assert harness.charm.clean_ca_file_from_workload("ca-app") + _unlink.assert_called_once() + _check_call.assert_called_once_with([UPDATE_CERTS_BIN_PATH]) + + def test_is_workload_running(harness): with patch("charm.snap.SnapCache") as _snap_cache: pg_snap = _snap_cache.return_value[POSTGRESQL_SNAP_NAME]