From f9530dadbae5b0dc089d0eb4f04c3b207254e329 Mon Sep 17 00:00:00 2001 From: Liam Young Date: Thu, 30 Mar 2023 08:23:30 +0000 Subject: [PATCH] Initial commit --- .gitignore | 10 + .stestr.conf | 3 + CONTRIBUTING.md | 33 + LICENSE | 202 +++ README.md | 16 + charmcraft.yaml | 21 + config.yaml | 28 + .../v0/database_requires.py | 496 +++++++ .../keystone_k8s/v0/cloud_credentials.py | 418 ++++++ .../keystone_k8s/v0/identity_credentials.py | 439 ++++++ .../keystone_k8s/v0/identity_service.py | 493 +++++++ .../keystone_k8s/v1/cloud_credentials.py | 439 ++++++ .../keystone_k8s/v1/identity_service.py | 518 +++++++ .../v0/kubernetes_service_patch.py | 280 ++++ lib/charms/ovn_central_k8s/v0/ovsdb.py | 218 +++ lib/charms/rabbitmq_k8s/v0/rabbitmq.py | 286 ++++ .../v1/tls_certificates.py | 1261 +++++++++++++++++ lib/charms/traefik_k8s/v1/ingress.py | 558 ++++++++ metadata.yaml | 22 + pyproject.toml | 33 + requirements.txt | 13 + src/charm.py | 176 +++ test-requirements.txt | 11 + tests/integration/test_charm.py | 35 + tests/unit/__init__.py | 15 + tests/unit/config.yaml | 1 + tests/unit/test_charm.py | 56 + tox.ini | 164 +++ 28 files changed, 6245 insertions(+) create mode 100644 .gitignore create mode 100644 .stestr.conf create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 charmcraft.yaml create mode 100644 config.yaml create mode 100644 lib/charms/data_platform_libs/v0/database_requires.py create mode 100644 lib/charms/keystone_k8s/v0/cloud_credentials.py create mode 100644 lib/charms/keystone_k8s/v0/identity_credentials.py create mode 100644 lib/charms/keystone_k8s/v0/identity_service.py create mode 100644 lib/charms/keystone_k8s/v1/cloud_credentials.py create mode 100644 lib/charms/keystone_k8s/v1/identity_service.py create mode 100644 lib/charms/observability_libs/v0/kubernetes_service_patch.py create mode 100644 lib/charms/ovn_central_k8s/v0/ovsdb.py create mode 100644 lib/charms/rabbitmq_k8s/v0/rabbitmq.py create mode 100644 lib/charms/tls_certificates_interface/v1/tls_certificates.py create mode 100644 lib/charms/traefik_k8s/v1/ingress.py create mode 100644 metadata.yaml create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100755 src/charm.py create mode 100644 test-requirements.txt create mode 100644 tests/integration/test_charm.py create mode 100644 tests/unit/__init__.py create mode 120000 tests/unit/config.yaml create mode 100644 tests/unit/test_charm.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33d25ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea +.vscode/ +.stestr/ diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..e4750de --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./tests/unit +top_dir=./tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2969183 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing + +To make contributions to this charm, you'll need a working [development setup](https://juju.is/docs/sdk/dev-setup). + +You can use the environments created by `tox` for development: + +```shell +tox --notest -e unit +source .tox/unit/bin/activate +``` + +## Testing + +This project uses `tox` for managing test environments. There are some pre-configured environments +that can be used for linting and formatting code when you're preparing contributions to the charm: + +```shell +tox -e fmt # update your code according to linting rules +tox -e lint # code style +tox -e unit # unit tests +tox -e integration # integration tests +tox # runs 'lint' and 'unit' environments +``` + +## Build the charm + +Build the charm in this git repository using: + +```shell +charmcraft pack +``` + + + +- [Read more](https://example.com) + +- [Contributing](CONTRIBUTING.md) + +- See the [Juju SDK documentation](https://juju.is/docs/sdk) for more information about developing and improving charms. diff --git a/charmcraft.yaml b/charmcraft.yaml new file mode 100644 index 0000000..340ca33 --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,21 @@ +# This file configures Charmcraft. +# See https://juju.is/docs/sdk/charmcraft-config for guidance. + +type: charm +bases: + - build-on: + - name: ubuntu + channel: "22.04" + run-on: + - name: ubuntu + channel: "22.04" + +parts: + charm: + build-packages: + - git + - libffi-dev + - libssl-dev + - pkg-config + - rustc + - cargo diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..af49127 --- /dev/null +++ b/config.yaml @@ -0,0 +1,28 @@ +options: + snap-channel: + default: "yoga/edge" + type: string + debug: + default: False + type: boolean + dns-domain: + default: "openstack.local" + type: string + dns-servers: + default: "8.8.8.8" + type: string + enable-gateway: + default: False + type: boolean + external-bridge: + default: "br-ex" + type: string + external-bridge-address: + default: + type: string + ip-address: + default: + type: string + physnet-name: + default: "physnet1" + type: string diff --git a/lib/charms/data_platform_libs/v0/database_requires.py b/lib/charms/data_platform_libs/v0/database_requires.py new file mode 100644 index 0000000..53d6191 --- /dev/null +++ b/lib/charms/data_platform_libs/v0/database_requires.py @@ -0,0 +1,496 @@ +# Copyright 2022 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. + +"""Relation 'requires' side abstraction for database relation. + +This library is a uniform interface to a selection of common database +metadata, with added custom events that add convenience to database management, +and methods to consume the application related data. + +Following an example of using the DatabaseCreatedEvent, in the context of the +application charm code: + +```python + +from charms.data_platform_libs.v0.database_requires import DatabaseRequires + +class ApplicationCharm(CharmBase): + # Application charm that connects to database charms. + + def __init__(self, *args): + super().__init__(*args) + + # Charm events defined in the database requires charm library. + self.database = DatabaseRequires(self, relation_name="database", database_name="database") + self.framework.observe(self.database.on.database_created, self._on_database_created) + + def _on_database_created(self, event: DatabaseCreatedEvent) -> None: + # Handle the created database + + # Create configuration file for app + config_file = self._render_app_config_file( + event.username, + event.password, + event.endpoints, + ) + + # Start application with rendered configuration + self._start_application(config_file) + + # Set active status + self.unit.status = ActiveStatus("received database credentials") +``` + +As shown above, the library provides some custom events to handle specific situations, +which are listed below: + +— database_created: event emitted when the requested database is created. +— endpoints_changed: event emitted when the read/write endpoints of the database have changed. +— read_only_endpoints_changed: event emitted when the read-only endpoints of the database + have changed. Event is not triggered if read/write endpoints changed too. + +If it is needed to connect multiple database clusters to the same relation endpoint +the application charm can implement the same code as if it would connect to only +one database cluster (like the above code example). + +To differentiate multiple clusters connected to the same relation endpoint +the application charm can use the name of the remote application: + +```python + +def _on_database_created(self, event: DatabaseCreatedEvent) -> None: + # Get the remote app name of the cluster that triggered this event + cluster = event.relation.app.name +``` + +It is also possible to provide an alias for each different database cluster/relation. + +So, it is possible to differentiate the clusters in two ways. +The first is to use the remote application name, i.e., `event.relation.app.name`, as above. + +The second way is to use different event handlers to handle each cluster events. +The implementation would be something like the following code: + +```python + +from charms.data_platform_libs.v0.database_requires import DatabaseRequires + +class ApplicationCharm(CharmBase): + # Application charm that connects to database charms. + + def __init__(self, *args): + super().__init__(*args) + + # Define the cluster aliases and one handler for each cluster database created event. + self.database = DatabaseRequires( + self, + relation_name="database", + database_name="database", + relations_aliases = ["cluster1", "cluster2"], + ) + self.framework.observe( + self.database.on.cluster1_database_created, self._on_cluster1_database_created + ) + self.framework.observe( + self.database.on.cluster2_database_created, self._on_cluster2_database_created + ) + + def _on_cluster1_database_created(self, event: DatabaseCreatedEvent) -> None: + # Handle the created database on the cluster named cluster1 + + # Create configuration file for app + config_file = self._render_app_config_file( + event.username, + event.password, + event.endpoints, + ) + ... + + def _on_cluster2_database_created(self, event: DatabaseCreatedEvent) -> None: + # Handle the created database on the cluster named cluster2 + + # Create configuration file for app + config_file = self._render_app_config_file( + event.username, + event.password, + event.endpoints, + ) + ... + +``` +""" + +import json +import logging +from collections import namedtuple +from datetime import datetime +from typing import List, Optional + +from ops.charm import ( + CharmEvents, + RelationChangedEvent, + RelationEvent, + RelationJoinedEvent, +) +from ops.framework import EventSource, Object +from ops.model import Relation + +# The unique Charmhub library identifier, never change it +LIBID = "0241e088ffa9440fb4e3126349b2fb62" + +# 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__) + + +class DatabaseEvent(RelationEvent): + """Base class for database events.""" + + @property + def endpoints(self) -> Optional[str]: + """Returns a comma separated list of read/write endpoints.""" + return self.relation.data[self.relation.app].get("endpoints") + + @property + def password(self) -> Optional[str]: + """Returns the password for the created user.""" + return self.relation.data[self.relation.app].get("password") + + @property + def read_only_endpoints(self) -> Optional[str]: + """Returns a comma separated list of read only endpoints.""" + return self.relation.data[self.relation.app].get("read-only-endpoints") + + @property + def replset(self) -> Optional[str]: + """Returns the replicaset name. + + MongoDB only. + """ + return self.relation.data[self.relation.app].get("replset") + + @property + def tls(self) -> Optional[str]: + """Returns whether TLS is configured.""" + return self.relation.data[self.relation.app].get("tls") + + @property + def tls_ca(self) -> Optional[str]: + """Returns TLS CA.""" + return self.relation.data[self.relation.app].get("tls-ca") + + @property + def uris(self) -> Optional[str]: + """Returns the connection URIs. + + MongoDB, Redis, OpenSearch and Kafka only. + """ + return self.relation.data[self.relation.app].get("uris") + + @property + def username(self) -> Optional[str]: + """Returns the created username.""" + return self.relation.data[self.relation.app].get("username") + + @property + def version(self) -> Optional[str]: + """Returns the version of the database. + + Version as informed by the database daemon. + """ + return self.relation.data[self.relation.app].get("version") + + +class DatabaseCreatedEvent(DatabaseEvent): + """Event emitted when a new database is created for use on this relation.""" + + +class DatabaseEndpointsChangedEvent(DatabaseEvent): + """Event emitted when the read/write endpoints are changed.""" + + +class DatabaseReadOnlyEndpointsChangedEvent(DatabaseEvent): + """Event emitted when the read only endpoints are changed.""" + + +class DatabaseEvents(CharmEvents): + """Database events. + + This class defines the events that the database can emit. + """ + + database_created = EventSource(DatabaseCreatedEvent) + endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) + read_only_endpoints_changed = EventSource(DatabaseReadOnlyEndpointsChangedEvent) + + +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 — keys that were deleted. +""" + + +class DatabaseRequires(Object): + """Requires-side of the database relation.""" + + on = DatabaseEvents() + + def __init__( + self, + charm, + relation_name: str, + database_name: str, + extra_user_roles: str = None, + relations_aliases: List[str] = None, + ): + """Manager of database client relations.""" + super().__init__(charm, relation_name) + self.charm = charm + self.database = database_name + self.extra_user_roles = extra_user_roles + self.local_app = self.charm.model.app + self.local_unit = self.charm.unit + self.relation_name = relation_name + self.relations_aliases = relations_aliases + self.framework.observe( + self.charm.on[relation_name].relation_joined, self._on_relation_joined_event + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, self._on_relation_changed_event + ) + + # Define custom event names for each alias. + if relations_aliases: + # Ensure the number of aliases does not exceed the maximum + # of connections allowed in the specific relation. + relation_connection_limit = self.charm.meta.requires[relation_name].limit + if len(relations_aliases) != relation_connection_limit: + raise ValueError( + f"The number of aliases must match the maximum number of connections allowed in the relation. " + f"Expected {relation_connection_limit}, got {len(relations_aliases)}" + ) + + for relation_alias in relations_aliases: + self.on.define_event(f"{relation_alias}_database_created", DatabaseCreatedEvent) + self.on.define_event( + f"{relation_alias}_endpoints_changed", DatabaseEndpointsChangedEvent + ) + self.on.define_event( + f"{relation_alias}_read_only_endpoints_changed", + DatabaseReadOnlyEndpointsChangedEvent, + ) + + def _assign_relation_alias(self, relation_id: int) -> None: + """Assigns an alias to a relation. + + This function writes in the unit data bag. + + Args: + relation_id: the identifier for a particular relation. + """ + # If no aliases were provided, return immediately. + if not self.relations_aliases: + return + + # Return if an alias was already assigned to this relation + # (like when there are more than one unit joining the relation). + if ( + self.charm.model.get_relation(self.relation_name, relation_id) + .data[self.local_unit] + .get("alias") + ): + return + + # Retrieve the available aliases (the ones that weren't assigned to any relation). + available_aliases = self.relations_aliases[:] + for relation in self.charm.model.relations[self.relation_name]: + alias = relation.data[self.local_unit].get("alias") + if alias: + logger.debug("Alias %s was already assigned to relation %d", alias, relation.id) + available_aliases.remove(alias) + + # Set the alias in the unit relation databag of the specific relation. + relation = self.charm.model.get_relation(self.relation_name, relation_id) + relation.data[self.local_unit].update({"alias": available_aliases[0]}) + + 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 local unit relation databag. + old_data = json.loads(event.relation.data[self.local_unit].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_unit].update({"data": json.dumps(new_data)}) + + # Return the diff with all possible changes. + return Diff(added, changed, deleted) + + def _emit_aliased_event(self, event: RelationChangedEvent, event_name: str) -> None: + """Emit an aliased event to a particular relation if it has an alias. + + Args: + event: the relation changed event that was received. + event_name: the name of the event to emit. + """ + alias = self._get_relation_alias(event.relation.id) + if alias: + getattr(self.on, f"{alias}_{event_name}").emit( + event.relation, app=event.app, unit=event.unit + ) + + def _get_relation_alias(self, relation_id: int) -> Optional[str]: + """Returns the relation alias. + + Args: + relation_id: the identifier for a particular relation. + + Returns: + the relation alias or None if the relation was not found. + """ + for relation in self.charm.model.relations[self.relation_name]: + if relation.id == relation_id: + return relation.data[self.local_unit].get("alias") + return None + + 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" + } + return data + + def _update_relation_data(self, relation_id: int, data: dict) -> None: + """Updates a 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. + data: dict containing the key-value pairs + that should be updated in the relation. + """ + if self.local_unit.is_leader(): + relation = self.charm.model.get_relation(self.relation_name, relation_id) + relation.data[self.local_app].update(data) + + def _on_relation_joined_event(self, event: RelationJoinedEvent) -> None: + """Event emitted when the application joins the database relation.""" + # If relations aliases were provided, assign one to the relation. + self._assign_relation_alias(event.relation.id) + + # Sets both database and extra user roles in the relation + # if the roles are provided. Otherwise, sets only the database. + if self.extra_user_roles: + self._update_relation_data( + event.relation.id, + { + "database": self.database, + "extra-user-roles": self.extra_user_roles, + }, + ) + else: + self._update_relation_data(event.relation.id, {"database": self.database}) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the database relation has changed.""" + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Check if the database is created + # (the database charm shared the credentials). + if "username" in diff.added and "password" in diff.added: + # Emit the default event (the one without an alias). + logger.info("database created at %s", datetime.now()) + self.on.database_created.emit(event.relation, app=event.app, unit=event.unit) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, "database_created") + + # To avoid unnecessary application restarts do not trigger + # “endpoints_changed“ event if “database_created“ is triggered. + return + + # Emit an endpoints changed event if the database + # added or changed this info in the relation databag. + if "endpoints" in diff.added or "endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("endpoints changed on %s", datetime.now()) + self.on.endpoints_changed.emit(event.relation, app=event.app, unit=event.unit) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, "endpoints_changed") + + # To avoid unnecessary application restarts do not trigger + # “read_only_endpoints_changed“ event if “endpoints_changed“ is triggered. + return + + # Emit a read only endpoints changed event if the database + # added or changed this info in the relation databag. + if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("read-only-endpoints changed on %s", datetime.now()) + self.on.read_only_endpoints_changed.emit( + event.relation, app=event.app, unit=event.unit + ) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, "read_only_endpoints_changed") + + @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/lib/charms/keystone_k8s/v0/cloud_credentials.py b/lib/charms/keystone_k8s/v0/cloud_credentials.py new file mode 100644 index 0000000..6253f73 --- /dev/null +++ b/lib/charms/keystone_k8s/v0/cloud_credentials.py @@ -0,0 +1,418 @@ +"""CloudCredentialsProvides and Requires module. + + +This library contains the Requires and Provides classes for handling +the cloud_credentials interface. + +Import `CloudCredentialsRequires` in your charm, with the charm object and the +relation name: + - self + - "cloud_credentials" + +Also provide additional parameters to the charm object: + - service + - internal_url + - public_url + - admin_url + - region + - username + - vhost + +Two events are also available to respond to: + - connected + - ready + - goneaway + +A basic example showing the usage of this relation follows: + +``` +from charms.keystone_k8s.v0.cloud_credentials import CloudCredentialsRequires + +class CloudCredentialsClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # CloudCredentials Requires + self.cloud_credentials = CloudCredentialsRequires( + self, "cloud_credentials", + service = "my-service" + internal_url = "http://internal-url" + public_url = "http://public-url" + admin_url = "http://admin-url" + region = "region" + ) + self.framework.observe( + self.cloud_credentials.on.connected, self._on_cloud_credentials_connected) + self.framework.observe( + self.cloud_credentials.on.ready, self._on_cloud_credentials_ready) + self.framework.observe( + self.cloud_credentials.on.goneaway, self._on_cloud_credentials_goneaway) + + def _on_cloud_credentials_connected(self, event): + '''React to the CloudCredentials connected event. + + This event happens when n CloudCredentials relation is added to the + model before credentials etc have been provided. + ''' + # Do something before the relation is complete + pass + + def _on_cloud_credentials_ready(self, event): + '''React to the CloudCredentials ready event. + + The CloudCredentials interface will use the provided config for the + request to the identity server. + ''' + # CloudCredentials Relation is ready. Do something with the completed relation. + pass + + def _on_cloud_credentials_goneaway(self, event): + '''React to the CloudCredentials goneaway event. + + This event happens when an CloudCredentials relation is removed. + ''' + # CloudCredentials Relation has goneaway. shutdown services or suchlike + pass +``` +""" + +import logging + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) +from ops.model import Relation + +# The unique Charmhub library identifier, never change it +LIBID = "a5d96cc2686c47eea554ce2210c2d24e" + +# 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 = 2 + +logger = logging.getLogger(__name__) + + +class CloudCredentialsConnectedEvent(EventBase): + """CloudCredentials connected Event.""" + + pass + + +class CloudCredentialsReadyEvent(EventBase): + """CloudCredentials ready for use Event.""" + + pass + + +class CloudCredentialsGoneAwayEvent(EventBase): + """CloudCredentials relation has gone-away Event""" + + pass + + +class CloudCredentialsServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(CloudCredentialsConnectedEvent) + ready = EventSource(CloudCredentialsReadyEvent) + goneaway = EventSource(CloudCredentialsGoneAwayEvent) + + +class CloudCredentialsRequires(Object): + """ + CloudCredentialsRequires class + """ + + on = CloudCredentialsServerEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_cloud_credentials_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_cloud_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_cloud_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_cloud_credentials_relation_broken, + ) + + def _on_cloud_credentials_relation_joined(self, event): + """CloudCredentials relation joined.""" + logging.debug("CloudCredentials on_joined") + self.on.connected.emit() + self.request_credentials() + + def _on_cloud_credentials_relation_changed(self, event): + """CloudCredentials relation changed.""" + logging.debug("CloudCredentials on_changed") + try: + self.on.ready.emit() + except (AttributeError, KeyError): + logger.exception('Error when emitting event') + + def _on_cloud_credentials_relation_broken(self, event): + """CloudCredentials relation broken.""" + logging.debug("CloudCredentials on_broken") + self.on.goneaway.emit() + + @property + def _cloud_credentials_rel(self) -> Relation: + """The CloudCredentials relation.""" + return self.framework.model.get_relation(self.relation_name) + + def get_remote_app_data(self, key: str) -> str: + """Return the value for the given key from remote app data.""" + data = self._cloud_credentials_rel.data[self._cloud_credentials_rel.app] + return data.get(key) + + @property + def api_version(self) -> str: + """Return the api_version.""" + return self.get_remote_app_data('api-version') + + @property + def auth_host(self) -> str: + """Return the auth_host.""" + return self.get_remote_app_data('auth-host') + + @property + def auth_port(self) -> str: + """Return the auth_port.""" + return self.get_remote_app_data('auth-port') + + @property + def auth_protocol(self) -> str: + """Return the auth_protocol.""" + return self.get_remote_app_data('auth-protocol') + + @property + def internal_host(self) -> str: + """Return the internal_host.""" + return self.get_remote_app_data('internal-host') + + @property + def internal_port(self) -> str: + """Return the internal_port.""" + return self.get_remote_app_data('internal-port') + + @property + def internal_protocol(self) -> str: + """Return the internal_protocol.""" + return self.get_remote_app_data('internal-protocol') + + @property + def username(self) -> str: + """Return the username.""" + return self.get_remote_app_data('username') + + @property + def password(self) -> str: + """Return the password.""" + return self.get_remote_app_data('password') + + @property + def project_name(self) -> str: + """Return the project name.""" + return self.get_remote_app_data('project-name') + + @property + def project_id(self) -> str: + """Return the project id.""" + return self.get_remote_app_data('project-id') + + @property + def user_domain_name(self) -> str: + """Return the name of the user domain.""" + return self.get_remote_app_data('user-domain-name') + + @property + def user_domain_id(self) -> str: + """Return the id of the user domain.""" + return self.get_remote_app_data('user-domain-id') + + @property + def project_domain_name(self) -> str: + """Return the name of the project domain.""" + return self.get_remote_app_data('project-domain-name') + + @property + def project_domain_id(self) -> str: + """Return the id of the project domain.""" + return self.get_remote_app_data('project-domain-id') + + @property + def region(self) -> str: + """Return the region for the auth urls.""" + return self.get_remote_app_data('region') + + def request_credentials(self) -> None: + """Request credentials from the CloudCredentials server.""" + if self.model.unit.is_leader(): + logging.debug(f'Requesting credentials for {self.charm.app.name}') + app_data = self._cloud_credentials_rel.data[self.charm.app] + app_data['username'] = self.charm.app.name + + +class HasCloudCredentialsClientsEvent(EventBase): + """Has CloudCredentialsClients Event.""" + + pass + + +class ReadyCloudCredentialsClientsEvent(EventBase): + """CloudCredentialsClients Ready Event.""" + + def __init__(self, handle, relation_id, relation_name, username): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.username = username + + def snapshot(self): + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "username": self.username, + } + + def restore(self, snapshot): + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.username = snapshot["username"] + + +class CloudCredentialsClientsGoneAwayEvent(EventBase): + """Has CloudCredentialsClientsGoneAwayEvent Event.""" + + pass + + +class CloudCredentialsClientEvents(ObjectEvents): + """Events class for `on`""" + + has_cloud_credentials_clients = EventSource( + HasCloudCredentialsClientsEvent + ) + ready_cloud_credentials_clients = EventSource( + ReadyCloudCredentialsClientsEvent + ) + cloud_credentials_clients_gone = EventSource( + CloudCredentialsClientsGoneAwayEvent + ) + + +class CloudCredentialsProvides(Object): + """ + CloudCredentialsProvides class + """ + + on = CloudCredentialsClientEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_cloud_credentials_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_cloud_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_cloud_credentials_relation_broken, + ) + + def _on_cloud_credentials_relation_joined(self, event): + """Handle CloudCredentials joined.""" + logging.debug("CloudCredentialsProvides on_joined") + self.on.has_cloud_credentials_clients.emit() + + def _on_cloud_credentials_relation_changed(self, event): + """Handle CloudCredentials changed.""" + logging.debug("CloudCredentials on_changed") + REQUIRED_KEYS = ['username'] + + values = [ + event.relation.data[event.relation.app].get(k) + for k in REQUIRED_KEYS + ] + # Validate data on the relation + if all(values): + username = event.relation.data[event.relation.app]['username'] + self.on.ready_cloud_credentials_clients.emit( + event.relation.id, + event.relation.name, + username, + ) + + def _on_cloud_credentials_relation_broken(self, event): + """Handle CloudCredentials broken.""" + logging.debug("CloudCredentialsProvides on_departed") + self.on.cloud_credentials_clients_gone.emit() + + def set_cloud_credentials(self, relation_name: int, + relation_id: str, + api_version: str, + auth_host: str, + auth_port: str, + auth_protocol: str, + internal_host: str, + internal_port: str, + internal_protocol: str, + username: str, + password: str, + project_name: str, + project_id: str, + user_domain_name: str, + user_domain_id: str, + project_domain_name: str, + project_domain_id: str, + region: str): + logging.debug("Setting cloud_credentials connection information.") + _cloud_credentials_rel = None + for relation in self.framework.model.relations[relation_name]: + if relation.id == relation_id: + _cloud_credentials_rel = relation + if not _cloud_credentials_rel: + # Relation has disappeared so don't send the data + return + app_data = _cloud_credentials_rel.data[self.charm.app] + app_data["api-version"] = api_version + app_data["auth-host"] = auth_host + app_data["auth-port"] = str(auth_port) + app_data["auth-protocol"] = auth_protocol + app_data["internal-host"] = internal_host + app_data["internal-port"] = str(internal_port) + app_data["internal-protocol"] = internal_protocol + app_data["username"] = username + app_data["password"] = password + app_data["project-name"] = project_name + app_data["project-id"] = project_id + app_data["user-domain-name"] = user_domain_name + app_data["user-domain-id"] = user_domain_id + app_data["project-domain-name"] = project_domain_name + app_data["project-domain-id"] = project_domain_id + app_data["region"] = region diff --git a/lib/charms/keystone_k8s/v0/identity_credentials.py b/lib/charms/keystone_k8s/v0/identity_credentials.py new file mode 100644 index 0000000..162a46a --- /dev/null +++ b/lib/charms/keystone_k8s/v0/identity_credentials.py @@ -0,0 +1,439 @@ +"""IdentityCredentialsProvides and Requires module. + + +This library contains the Requires and Provides classes for handling +the identity_credentials interface. + +Import `IdentityCredentialsRequires` in your charm, with the charm object and the +relation name: + - self + - "identity_credentials" + +Also provide additional parameters to the charm object: + - service + - internal_url + - public_url + - admin_url + - region + - username + - vhost + +Two events are also available to respond to: + - connected + - ready + - goneaway + +A basic example showing the usage of this relation follows: + +``` +from charms.keystone_k8s.v0.identity_credentials import IdentityCredentialsRequires + +class IdentityCredentialsClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # IdentityCredentials Requires + self.identity_credentials = IdentityCredentialsRequires( + self, "identity_credentials", + service = "my-service" + internal_url = "http://internal-url" + public_url = "http://public-url" + admin_url = "http://admin-url" + region = "region" + ) + self.framework.observe( + self.identity_credentials.on.connected, self._on_identity_credentials_connected) + self.framework.observe( + self.identity_credentials.on.ready, self._on_identity_credentials_ready) + self.framework.observe( + self.identity_credentials.on.goneaway, self._on_identity_credentials_goneaway) + + def _on_identity_credentials_connected(self, event): + '''React to the IdentityCredentials connected event. + + This event happens when IdentityCredentials relation is added to the + model before credentials etc have been provided. + ''' + # Do something before the relation is complete + pass + + def _on_identity_credentials_ready(self, event): + '''React to the IdentityCredentials ready event. + + The IdentityCredentials interface will use the provided config for the + request to the identity server. + ''' + # IdentityCredentials Relation is ready. Do something with the completed relation. + pass + + def _on_identity_credentials_goneaway(self, event): + '''React to the IdentityCredentials goneaway event. + + This event happens when an IdentityCredentials relation is removed. + ''' + # IdentityCredentials Relation has goneaway. shutdown services or suchlike + pass +``` +""" + +import logging + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) +from ops.model import ( + Relation, + SecretNotFoundError, +) + +# The unique Charmhub library identifier, never change it +LIBID = "b5fa18d4427c4ab9a269c3a2fbed545c" + +# 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 = 1 + +logger = logging.getLogger(__name__) + + +class IdentityCredentialsConnectedEvent(EventBase): + """IdentityCredentials connected Event.""" + + pass + + +class IdentityCredentialsReadyEvent(EventBase): + """IdentityCredentials ready for use Event.""" + + pass + + +class IdentityCredentialsGoneAwayEvent(EventBase): + """IdentityCredentials relation has gone-away Event""" + + pass + + +class IdentityCredentialsServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(IdentityCredentialsConnectedEvent) + ready = EventSource(IdentityCredentialsReadyEvent) + goneaway = EventSource(IdentityCredentialsGoneAwayEvent) + + +class IdentityCredentialsRequires(Object): + """ + IdentityCredentialsRequires class + """ + + on = IdentityCredentialsServerEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_credentials_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_identity_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_credentials_relation_broken, + ) + + def _on_identity_credentials_relation_joined(self, event): + """IdentityCredentials relation joined.""" + logging.debug("IdentityCredentials on_joined") + self.on.connected.emit() + self.request_credentials() + + def _on_identity_credentials_relation_changed(self, event): + """IdentityCredentials relation changed.""" + logging.debug("IdentityCredentials on_changed") + try: + self.on.ready.emit() + except (AttributeError, KeyError): + logger.exception('Error when emitting event') + + def _on_identity_credentials_relation_broken(self, event): + """IdentityCredentials relation broken.""" + logging.debug("IdentityCredentials on_broken") + self.on.goneaway.emit() + + @property + def _identity_credentials_rel(self) -> Relation: + """The IdentityCredentials relation.""" + return self.framework.model.get_relation(self.relation_name) + + def get_remote_app_data(self, key: str) -> str: + """Return the value for the given key from remote app data.""" + data = self._identity_credentials_rel.data[self._identity_credentials_rel.app] + return data.get(key) + + @property + def api_version(self) -> str: + """Return the api_version.""" + return self.get_remote_app_data('api-version') + + @property + def auth_host(self) -> str: + """Return the auth_host.""" + return self.get_remote_app_data('auth-host') + + @property + def auth_port(self) -> str: + """Return the auth_port.""" + return self.get_remote_app_data('auth-port') + + @property + def auth_protocol(self) -> str: + """Return the auth_protocol.""" + return self.get_remote_app_data('auth-protocol') + + @property + def internal_host(self) -> str: + """Return the internal_host.""" + return self.get_remote_app_data('internal-host') + + @property + def internal_port(self) -> str: + """Return the internal_port.""" + return self.get_remote_app_data('internal-port') + + @property + def internal_protocol(self) -> str: + """Return the internal_protocol.""" + return self.get_remote_app_data('internal-protocol') + + @property + def credentials(self) -> str: + return self.get_remote_app_data('credentials') + + @property + def username(self) -> str: + credentials_id = self.get_remote_app_data('credentials') + if not credentials_id: + return None + + try: + credentials = self.charm.model.get_secret(id=credentials_id) + return credentials.get_content().get("username") + except SecretNotFoundError: + logger.warning(f"Secret {credentials_id} not found") + return None + + @property + def password(self) -> str: + credentials_id = self.get_remote_app_data('credentials') + if not credentials_id: + return None + + try: + credentials = self.charm.model.get_secret(id=credentials_id) + return credentials.get_content().get("password") + except SecretNotFoundError: + logger.warning(f"Secret {credentials_id} not found") + return None + + @property + def project_name(self) -> str: + """Return the project name.""" + return self.get_remote_app_data('project-name') + + @property + def project_id(self) -> str: + """Return the project id.""" + return self.get_remote_app_data('project-id') + + @property + def user_domain_name(self) -> str: + """Return the name of the user domain.""" + return self.get_remote_app_data('user-domain-name') + + @property + def user_domain_id(self) -> str: + """Return the id of the user domain.""" + return self.get_remote_app_data('user-domain-id') + + @property + def project_domain_name(self) -> str: + """Return the name of the project domain.""" + return self.get_remote_app_data('project-domain-name') + + @property + def project_domain_id(self) -> str: + """Return the id of the project domain.""" + return self.get_remote_app_data('project-domain-id') + + @property + def region(self) -> str: + """Return the region for the auth urls.""" + return self.get_remote_app_data('region') + + def request_credentials(self) -> None: + """Request credentials from the IdentityCredentials server.""" + if self.model.unit.is_leader(): + logging.debug(f'Requesting credentials for {self.charm.app.name}') + app_data = self._identity_credentials_rel.data[self.charm.app] + app_data['username'] = self.charm.app.name + + +class HasIdentityCredentialsClientsEvent(EventBase): + """Has IdentityCredentialsClients Event.""" + + pass + + +class ReadyIdentityCredentialsClientsEvent(EventBase): + """IdentityCredentialsClients Ready Event.""" + + def __init__(self, handle, relation_id, relation_name, username): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.username = username + + def snapshot(self): + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "username": self.username, + } + + def restore(self, snapshot): + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.username = snapshot["username"] + + +class IdentityCredentialsClientsGoneAwayEvent(EventBase): + """Has IdentityCredentialsClientsGoneAwayEvent Event.""" + + pass + + +class IdentityCredentialsClientEvents(ObjectEvents): + """Events class for `on`""" + + has_identity_credentials_clients = EventSource( + HasIdentityCredentialsClientsEvent + ) + ready_identity_credentials_clients = EventSource( + ReadyIdentityCredentialsClientsEvent + ) + identity_credentials_clients_gone = EventSource( + IdentityCredentialsClientsGoneAwayEvent + ) + + +class IdentityCredentialsProvides(Object): + """ + IdentityCredentialsProvides class + """ + + on = IdentityCredentialsClientEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_credentials_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_credentials_relation_broken, + ) + + def _on_identity_credentials_relation_joined(self, event): + """Handle IdentityCredentials joined.""" + logging.debug("IdentityCredentialsProvides on_joined") + self.on.has_identity_credentials_clients.emit() + + def _on_identity_credentials_relation_changed(self, event): + """Handle IdentityCredentials changed.""" + logging.debug("IdentityCredentials on_changed") + REQUIRED_KEYS = ['username'] + + values = [ + event.relation.data[event.relation.app].get(k) + for k in REQUIRED_KEYS + ] + # Validate data on the relation + if all(values): + username = event.relation.data[event.relation.app]['username'] + self.on.ready_identity_credentials_clients.emit( + event.relation.id, + event.relation.name, + username, + ) + + def _on_identity_credentials_relation_broken(self, event): + """Handle IdentityCredentials broken.""" + logging.debug("IdentityCredentialsProvides on_departed") + self.on.identity_credentials_clients_gone.emit() + + def set_identity_credentials(self, relation_name: int, + relation_id: str, + api_version: str, + auth_host: str, + auth_port: str, + auth_protocol: str, + internal_host: str, + internal_port: str, + internal_protocol: str, + credentials: str, + project_name: str, + project_id: str, + user_domain_name: str, + user_domain_id: str, + project_domain_name: str, + project_domain_id: str, + region: str): + logging.debug("Setting identity_credentials connection information.") + _identity_credentials_rel = None + for relation in self.framework.model.relations[relation_name]: + if relation.id == relation_id: + _identity_credentials_rel = relation + if not _identity_credentials_rel: + # Relation has disappeared so don't send the data + return + app_data = _identity_credentials_rel.data[self.charm.app] + app_data["api-version"] = api_version + app_data["auth-host"] = auth_host + app_data["auth-port"] = str(auth_port) + app_data["auth-protocol"] = auth_protocol + app_data["internal-host"] = internal_host + app_data["internal-port"] = str(internal_port) + app_data["internal-protocol"] = internal_protocol + app_data["credentials"] = credentials + app_data["project-name"] = project_name + app_data["project-id"] = project_id + app_data["user-domain-name"] = user_domain_name + app_data["user-domain-id"] = user_domain_id + app_data["project-domain-name"] = project_domain_name + app_data["project-domain-id"] = project_domain_id + app_data["region"] = region diff --git a/lib/charms/keystone_k8s/v0/identity_service.py b/lib/charms/keystone_k8s/v0/identity_service.py new file mode 100644 index 0000000..e8d2773 --- /dev/null +++ b/lib/charms/keystone_k8s/v0/identity_service.py @@ -0,0 +1,493 @@ +"""IdentityServiceProvides and Requires module. + + +This library contains the Requires and Provides classes for handling +the identity_service interface. + +Import `IdentityServiceRequires` in your charm, with the charm object and the +relation name: + - self + - "identity_service" + +Also provide additional parameters to the charm object: + - service + - internal_url + - public_url + - admin_url + - region + - username + - vhost + +Two events are also available to respond to: + - connected + - ready + - goneaway + +A basic example showing the usage of this relation follows: + +``` +from charms.keystone_k8s.v0.identity_service import IdentityServiceRequires + +class IdentityServiceClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # IdentityService Requires + self.identity_service = IdentityServiceRequires( + self, "identity_service", + service = "my-service" + internal_url = "http://internal-url" + public_url = "http://public-url" + admin_url = "http://admin-url" + region = "region" + ) + self.framework.observe( + self.identity_service.on.connected, self._on_identity_service_connected) + self.framework.observe( + self.identity_service.on.ready, self._on_identity_service_ready) + self.framework.observe( + self.identity_service.on.goneaway, self._on_identity_service_goneaway) + + def _on_identity_service_connected(self, event): + '''React to the IdentityService connected event. + + This event happens when n IdentityService relation is added to the + model before credentials etc have been provided. + ''' + # Do something before the relation is complete + pass + + def _on_identity_service_ready(self, event): + '''React to the IdentityService ready event. + + The IdentityService interface will use the provided config for the + request to the identity server. + ''' + # IdentityService Relation is ready. Do something with the completed relation. + pass + + def _on_identity_service_goneaway(self, event): + '''React to the IdentityService goneaway event. + + This event happens when an IdentityService relation is removed. + ''' + # IdentityService Relation has goneaway. shutdown services or suchlike + pass +``` +""" + +import json +import logging + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) +from ops.model import Relation + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "0fa7fe7236c14c6e9624acf232b9a3b0" + +# 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 = 2 + + +logger = logging.getLogger(__name__) + + +class IdentityServiceConnectedEvent(EventBase): + """IdentityService connected Event.""" + + pass + + +class IdentityServiceReadyEvent(EventBase): + """IdentityService ready for use Event.""" + + pass + + +class IdentityServiceGoneAwayEvent(EventBase): + """IdentityService relation has gone-away Event""" + + pass + + +class IdentityServiceServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(IdentityServiceConnectedEvent) + ready = EventSource(IdentityServiceReadyEvent) + goneaway = EventSource(IdentityServiceGoneAwayEvent) + + +class IdentityServiceRequires(Object): + """ + IdentityServiceRequires class + """ + + on = IdentityServiceServerEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name: str, service_endpoints: dict, + region: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.service_endpoints = service_endpoints + self.region = region + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_service_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_service_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_identity_service_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_service_relation_broken, + ) + + def _on_identity_service_relation_joined(self, event): + """IdentityService relation joined.""" + logging.debug("IdentityService on_joined") + self.on.connected.emit() + self.register_services( + self.service_endpoints, + self.region) + + def _on_identity_service_relation_changed(self, event): + """IdentityService relation changed.""" + logging.debug("IdentityService on_changed") + try: + self.service_password + self.on.ready.emit() + except (AttributeError, KeyError): + pass + + def _on_identity_service_relation_broken(self, event): + """IdentityService relation broken.""" + logging.debug("IdentityService on_broken") + self.on.goneaway.emit() + + @property + def _identity_service_rel(self) -> Relation: + """The IdentityService relation.""" + return self.framework.model.get_relation(self.relation_name) + + def get_remote_app_data(self, key: str) -> str: + """Return the value for the given key from remote app data.""" + data = self._identity_service_rel.data[self._identity_service_rel.app] + return data.get(key) + + @property + def api_version(self) -> str: + """Return the api_version.""" + return self.get_remote_app_data('api-version') + + @property + def auth_host(self) -> str: + """Return the auth_host.""" + return self.get_remote_app_data('auth-host') + + @property + def auth_port(self) -> str: + """Return the auth_port.""" + return self.get_remote_app_data('auth-port') + + @property + def auth_protocol(self) -> str: + """Return the auth_protocol.""" + return self.get_remote_app_data('auth-protocol') + + @property + def internal_host(self) -> str: + """Return the internal_host.""" + return self.get_remote_app_data('internal-host') + + @property + def internal_port(self) -> str: + """Return the internal_port.""" + return self.get_remote_app_data('internal-port') + + @property + def internal_protocol(self) -> str: + """Return the internal_protocol.""" + return self.get_remote_app_data('internal-protocol') + + @property + def admin_domain_name(self) -> str: + """Return the admin_domain_name.""" + return self.get_remote_app_data('admin-domain-name') + + @property + def admin_domain_id(self) -> str: + """Return the admin_domain_id.""" + return self.get_remote_app_data('admin-domain-id') + + @property + def admin_project_name(self) -> str: + """Return the admin_project_name.""" + return self.get_remote_app_data('admin-project-name') + + @property + def admin_project_id(self) -> str: + """Return the admin_project_id.""" + return self.get_remote_app_data('admin-project-id') + + @property + def admin_user_name(self) -> str: + """Return the admin_user_name.""" + return self.get_remote_app_data('admin-user-name') + + @property + def admin_user_id(self) -> str: + """Return the admin_user_id.""" + return self.get_remote_app_data('admin-user-id') + + @property + def service_domain_name(self) -> str: + """Return the service_domain_name.""" + return self.get_remote_app_data('service-domain-name') + + @property + def service_domain_id(self) -> str: + """Return the service_domain_id.""" + return self.get_remote_app_data('service-domain-id') + + @property + def service_host(self) -> str: + """Return the service_host.""" + return self.get_remote_app_data('service-host') + + @property + def service_password(self) -> str: + """Return the service_password.""" + return self.get_remote_app_data('service-password') + + @property + def service_port(self) -> str: + """Return the service_port.""" + return self.get_remote_app_data('service-port') + + @property + def service_protocol(self) -> str: + """Return the service_protocol.""" + return self.get_remote_app_data('service-protocol') + + @property + def service_project_name(self) -> str: + """Return the service_project_name.""" + return self.get_remote_app_data('service-project-name') + + @property + def service_project_id(self) -> str: + """Return the service_project_id.""" + return self.get_remote_app_data('service-project-id') + + @property + def service_user_name(self) -> str: + """Return the service_user_name.""" + return self.get_remote_app_data('service-user-name') + + @property + def service_user_id(self) -> str: + """Return the service_user_id.""" + return self.get_remote_app_data('service-user-id') + + @property + def internal_auth_url(self) -> str: + """Return the internal_auth_url.""" + return self.get_remote_app_data('internal-auth-url') + + @property + def admin_auth_url(self) -> str: + """Return the admin_auth_url.""" + return self.get_remote_app_data('admin-auth-url') + + @property + def public_auth_url(self) -> str: + """Return the public_auth_url.""" + return self.get_remote_app_data('public-auth-url') + + def register_services(self, service_endpoints: dict, + region: str) -> None: + """Request access to the IdentityService server.""" + if self.model.unit.is_leader(): + logging.debug("Requesting service registration") + app_data = self._identity_service_rel.data[self.charm.app] + app_data["service-endpoints"] = json.dumps( + service_endpoints, sort_keys=True + ) + app_data["region"] = region + + +class HasIdentityServiceClientsEvent(EventBase): + """Has IdentityServiceClients Event.""" + + pass + + +class ReadyIdentityServiceClientsEvent(EventBase): + """IdentityServiceClients Ready Event.""" + + def __init__(self, handle, relation_id, relation_name, service_endpoints, + region, client_app_name): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.service_endpoints = service_endpoints + self.region = region + self.client_app_name = client_app_name + + def snapshot(self): + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "service_endpoints": self.service_endpoints, + "client_app_name": self.client_app_name, + "region": self.region} + + def restore(self, snapshot): + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.service_endpoints = snapshot["service_endpoints"] + self.region = snapshot["region"] + self.client_app_name = snapshot["client_app_name"] + + +class IdentityServiceClientEvents(ObjectEvents): + """Events class for `on`""" + + has_identity_service_clients = EventSource(HasIdentityServiceClientsEvent) + ready_identity_service_clients = EventSource(ReadyIdentityServiceClientsEvent) + + +class IdentityServiceProvides(Object): + """ + IdentityServiceProvides class + """ + + on = IdentityServiceClientEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_service_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_service_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_service_relation_broken, + ) + + def _on_identity_service_relation_joined(self, event): + """Handle IdentityService joined.""" + logging.debug("IdentityService on_joined") + self.on.has_identity_service_clients.emit() + + def _on_identity_service_relation_changed(self, event): + """Handle IdentityService changed.""" + logging.debug("IdentityService on_changed") + REQUIRED_KEYS = [ + 'service-endpoints', + 'region'] + + values = [ + event.relation.data[event.relation.app].get(k) + for k in REQUIRED_KEYS + ] + # Validate data on the relation + if all(values): + service_eps = json.loads( + event.relation.data[event.relation.app]['service-endpoints']) + self.on.ready_identity_service_clients.emit( + event.relation.id, + event.relation.name, + service_eps, + event.relation.data[event.relation.app]['region'], + event.relation.app.name) + + def _on_identity_service_relation_broken(self, event): + """Handle IdentityService broken.""" + logging.debug("IdentityServiceProvides on_departed") + # TODO clear data on the relation + + def set_identity_service_credentials(self, relation_name: int, + relation_id: str, + api_version: str, + auth_host: str, + auth_port: str, + auth_protocol: str, + internal_host: str, + internal_port: str, + internal_protocol: str, + service_host: str, + service_port: str, + service_protocol: str, + admin_domain: str, + admin_project: str, + admin_user: str, + service_domain: str, + service_password: str, + service_project: str, + service_user: str, + internal_auth_url: str, + admin_auth_url: str, + public_auth_url: str): + logging.debug("Setting identity_service connection information.") + _identity_service_rel = None + for relation in self.framework.model.relations[relation_name]: + if relation.id == relation_id: + _identity_service_rel = relation + if not _identity_service_rel: + # Relation has disappeared so skip send of data + return + app_data = _identity_service_rel.data[self.charm.app] + app_data["api-version"] = api_version + app_data["auth-host"] = auth_host + app_data["auth-port"] = str(auth_port) + app_data["auth-protocol"] = auth_protocol + app_data["internal-host"] = internal_host + app_data["internal-port"] = str(internal_port) + app_data["internal-protocol"] = internal_protocol + app_data["service-host"] = service_host + app_data["service-port"] = str(service_port) + app_data["service-protocol"] = service_protocol + app_data["admin-domain-name"] = admin_domain.name + app_data["admin-domain-id"] = admin_domain.id + app_data["admin-project-name"] = admin_project.name + app_data["admin-project-id"] = admin_project.id + app_data["admin-user-name"] = admin_user.name + app_data["admin-user-id"] = admin_user.id + app_data["service-domain-name"] = service_domain.name + app_data["service-domain-id"] = service_domain.id + app_data["service-project-name"] = service_project.name + app_data["service-project-id"] = service_project.id + app_data["service-user-name"] = service_user.name + app_data["service-user-id"] = service_user.id + app_data["service-password"] = service_password + app_data["internal-auth-url"] = internal_auth_url + app_data["admin-auth-url"] = admin_auth_url + app_data["public-auth-url"] = public_auth_url diff --git a/lib/charms/keystone_k8s/v1/cloud_credentials.py b/lib/charms/keystone_k8s/v1/cloud_credentials.py new file mode 100644 index 0000000..9ff0a8d --- /dev/null +++ b/lib/charms/keystone_k8s/v1/cloud_credentials.py @@ -0,0 +1,439 @@ +"""CloudCredentialsProvides and Requires module. + + +This library contains the Requires and Provides classes for handling +the cloud_credentials interface. + +Import `CloudCredentialsRequires` in your charm, with the charm object and the +relation name: + - self + - "cloud_credentials" + +Also provide additional parameters to the charm object: + - service + - internal_url + - public_url + - admin_url + - region + - username + - vhost + +Two events are also available to respond to: + - connected + - ready + - goneaway + +A basic example showing the usage of this relation follows: + +``` +from charms.keystone_k8s.v0.cloud_credentials import CloudCredentialsRequires + +class CloudCredentialsClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # CloudCredentials Requires + self.cloud_credentials = CloudCredentialsRequires( + self, "cloud_credentials", + service = "my-service" + internal_url = "http://internal-url" + public_url = "http://public-url" + admin_url = "http://admin-url" + region = "region" + ) + self.framework.observe( + self.cloud_credentials.on.connected, self._on_cloud_credentials_connected) + self.framework.observe( + self.cloud_credentials.on.ready, self._on_cloud_credentials_ready) + self.framework.observe( + self.cloud_credentials.on.goneaway, self._on_cloud_credentials_goneaway) + + def _on_cloud_credentials_connected(self, event): + '''React to the CloudCredentials connected event. + + This event happens when n CloudCredentials relation is added to the + model before credentials etc have been provided. + ''' + # Do something before the relation is complete + pass + + def _on_cloud_credentials_ready(self, event): + '''React to the CloudCredentials ready event. + + The CloudCredentials interface will use the provided config for the + request to the identity server. + ''' + # CloudCredentials Relation is ready. Do something with the completed relation. + pass + + def _on_cloud_credentials_goneaway(self, event): + '''React to the CloudCredentials goneaway event. + + This event happens when an CloudCredentials relation is removed. + ''' + # CloudCredentials Relation has goneaway. shutdown services or suchlike + pass +``` +""" + +import logging + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) +from ops.model import ( + Relation, + SecretNotFoundError, +) + +# The unique Charmhub library identifier, never change it +LIBID = "a5d96cc2686c47eea554ce2210c2d24e" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 0 + +logger = logging.getLogger(__name__) + + +class CloudCredentialsConnectedEvent(EventBase): + """CloudCredentials connected Event.""" + + pass + + +class CloudCredentialsReadyEvent(EventBase): + """CloudCredentials ready for use Event.""" + + pass + + +class CloudCredentialsGoneAwayEvent(EventBase): + """CloudCredentials relation has gone-away Event""" + + pass + + +class CloudCredentialsServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(CloudCredentialsConnectedEvent) + ready = EventSource(CloudCredentialsReadyEvent) + goneaway = EventSource(CloudCredentialsGoneAwayEvent) + + +class CloudCredentialsRequires(Object): + """ + CloudCredentialsRequires class + """ + + on = CloudCredentialsServerEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_cloud_credentials_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_cloud_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_cloud_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_cloud_credentials_relation_broken, + ) + + def _on_cloud_credentials_relation_joined(self, event): + """CloudCredentials relation joined.""" + logging.debug("CloudCredentials on_joined") + self.on.connected.emit() + self.request_credentials() + + def _on_cloud_credentials_relation_changed(self, event): + """CloudCredentials relation changed.""" + logging.debug("CloudCredentials on_changed") + try: + self.on.ready.emit() + except (AttributeError, KeyError): + logger.exception('Error when emitting event') + + def _on_cloud_credentials_relation_broken(self, event): + """CloudCredentials relation broken.""" + logging.debug("CloudCredentials on_broken") + self.on.goneaway.emit() + + @property + def _cloud_credentials_rel(self) -> Relation: + """The CloudCredentials relation.""" + return self.framework.model.get_relation(self.relation_name) + + def get_remote_app_data(self, key: str) -> str: + """Return the value for the given key from remote app data.""" + data = self._cloud_credentials_rel.data[self._cloud_credentials_rel.app] + return data.get(key) + + @property + def api_version(self) -> str: + """Return the api_version.""" + return self.get_remote_app_data('api-version') + + @property + def auth_host(self) -> str: + """Return the auth_host.""" + return self.get_remote_app_data('auth-host') + + @property + def auth_port(self) -> str: + """Return the auth_port.""" + return self.get_remote_app_data('auth-port') + + @property + def auth_protocol(self) -> str: + """Return the auth_protocol.""" + return self.get_remote_app_data('auth-protocol') + + @property + def internal_host(self) -> str: + """Return the internal_host.""" + return self.get_remote_app_data('internal-host') + + @property + def internal_port(self) -> str: + """Return the internal_port.""" + return self.get_remote_app_data('internal-port') + + @property + def internal_protocol(self) -> str: + """Return the internal_protocol.""" + return self.get_remote_app_data('internal-protocol') + + @property + def credentials(self) -> str: + return self.get_remote_app_data('credentials') + + @property + def username(self) -> str: + credentials_id = self.get_remote_app_data('credentials') + if not credentials_id: + return None + + try: + credentials = self.charm.model.get_secret(id=credentials_id) + return credentials.get_content().get("username") + except SecretNotFoundError: + logger.warning(f"Secret {credentials_id} not found") + return None + + @property + def password(self) -> str: + credentials_id = self.get_remote_app_data('credentials') + if not credentials_id: + return None + + try: + credentials = self.charm.model.get_secret(id=credentials_id) + return credentials.get_content().get("password") + except SecretNotFoundError: + logger.warning(f"Secret {credentials_id} not found") + return None + + @property + def project_name(self) -> str: + """Return the project name.""" + return self.get_remote_app_data('project-name') + + @property + def project_id(self) -> str: + """Return the project id.""" + return self.get_remote_app_data('project-id') + + @property + def user_domain_name(self) -> str: + """Return the name of the user domain.""" + return self.get_remote_app_data('user-domain-name') + + @property + def user_domain_id(self) -> str: + """Return the id of the user domain.""" + return self.get_remote_app_data('user-domain-id') + + @property + def project_domain_name(self) -> str: + """Return the name of the project domain.""" + return self.get_remote_app_data('project-domain-name') + + @property + def project_domain_id(self) -> str: + """Return the id of the project domain.""" + return self.get_remote_app_data('project-domain-id') + + @property + def region(self) -> str: + """Return the region for the auth urls.""" + return self.get_remote_app_data('region') + + def request_credentials(self) -> None: + """Request credentials from the CloudCredentials server.""" + if self.model.unit.is_leader(): + logging.debug(f'Requesting credentials for {self.charm.app.name}') + app_data = self._cloud_credentials_rel.data[self.charm.app] + app_data['username'] = self.charm.app.name + + +class HasCloudCredentialsClientsEvent(EventBase): + """Has CloudCredentialsClients Event.""" + + pass + + +class ReadyCloudCredentialsClientsEvent(EventBase): + """CloudCredentialsClients Ready Event.""" + + def __init__(self, handle, relation_id, relation_name, username): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.username = username + + def snapshot(self): + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "username": self.username, + } + + def restore(self, snapshot): + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.username = snapshot["username"] + + +class CloudCredentialsClientsGoneAwayEvent(EventBase): + """Has CloudCredentialsClientsGoneAwayEvent Event.""" + + pass + + +class CloudCredentialsClientEvents(ObjectEvents): + """Events class for `on`""" + + has_cloud_credentials_clients = EventSource( + HasCloudCredentialsClientsEvent + ) + ready_cloud_credentials_clients = EventSource( + ReadyCloudCredentialsClientsEvent + ) + cloud_credentials_clients_gone = EventSource( + CloudCredentialsClientsGoneAwayEvent + ) + + +class CloudCredentialsProvides(Object): + """ + CloudCredentialsProvides class + """ + + on = CloudCredentialsClientEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_cloud_credentials_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_cloud_credentials_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_cloud_credentials_relation_broken, + ) + + def _on_cloud_credentials_relation_joined(self, event): + """Handle CloudCredentials joined.""" + logging.debug("CloudCredentialsProvides on_joined") + self.on.has_cloud_credentials_clients.emit() + + def _on_cloud_credentials_relation_changed(self, event): + """Handle CloudCredentials changed.""" + logging.debug("CloudCredentials on_changed") + REQUIRED_KEYS = ['username'] + + values = [ + event.relation.data[event.relation.app].get(k) + for k in REQUIRED_KEYS + ] + # Validate data on the relation + if all(values): + username = event.relation.data[event.relation.app]['username'] + self.on.ready_cloud_credentials_clients.emit( + event.relation.id, + event.relation.name, + username, + ) + + def _on_cloud_credentials_relation_broken(self, event): + """Handle CloudCredentials broken.""" + logging.debug("CloudCredentialsProvides on_departed") + self.on.cloud_credentials_clients_gone.emit() + + def set_cloud_credentials(self, relation_name: int, + relation_id: str, + api_version: str, + auth_host: str, + auth_port: str, + auth_protocol: str, + internal_host: str, + internal_port: str, + internal_protocol: str, + credentials: str, + project_name: str, + project_id: str, + user_domain_name: str, + user_domain_id: str, + project_domain_name: str, + project_domain_id: str, + region: str): + logging.debug("Setting cloud_credentials connection information.") + _cloud_credentials_rel = None + for relation in self.framework.model.relations[relation_name]: + if relation.id == relation_id: + _cloud_credentials_rel = relation + if not _cloud_credentials_rel: + # Relation has disappeared so don't send the data + return + app_data = _cloud_credentials_rel.data[self.charm.app] + app_data["api-version"] = api_version + app_data["auth-host"] = auth_host + app_data["auth-port"] = str(auth_port) + app_data["auth-protocol"] = auth_protocol + app_data["internal-host"] = internal_host + app_data["internal-port"] = str(internal_port) + app_data["internal-protocol"] = internal_protocol + app_data["credentials"] = credentials + app_data["project-name"] = project_name + app_data["project-id"] = project_id + app_data["user-domain-name"] = user_domain_name + app_data["user-domain-id"] = user_domain_id + app_data["project-domain-name"] = project_domain_name + app_data["project-domain-id"] = project_domain_id + app_data["region"] = region diff --git a/lib/charms/keystone_k8s/v1/identity_service.py b/lib/charms/keystone_k8s/v1/identity_service.py new file mode 100644 index 0000000..3555662 --- /dev/null +++ b/lib/charms/keystone_k8s/v1/identity_service.py @@ -0,0 +1,518 @@ +"""IdentityServiceProvides and Requires module. + + +This library contains the Requires and Provides classes for handling +the identity_service interface. + +Import `IdentityServiceRequires` in your charm, with the charm object and the +relation name: + - self + - "identity_service" + +Also provide additional parameters to the charm object: + - service + - internal_url + - public_url + - admin_url + - region + - username + - vhost + +Two events are also available to respond to: + - connected + - ready + - goneaway + +A basic example showing the usage of this relation follows: + +``` +from charms.keystone_k8s.v1.identity_service import IdentityServiceRequires + +class IdentityServiceClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # IdentityService Requires + self.identity_service = IdentityServiceRequires( + self, "identity_service", + service = "my-service" + internal_url = "http://internal-url" + public_url = "http://public-url" + admin_url = "http://admin-url" + region = "region" + ) + self.framework.observe( + self.identity_service.on.connected, self._on_identity_service_connected) + self.framework.observe( + self.identity_service.on.ready, self._on_identity_service_ready) + self.framework.observe( + self.identity_service.on.goneaway, self._on_identity_service_goneaway) + + def _on_identity_service_connected(self, event): + '''React to the IdentityService connected event. + + This event happens when n IdentityService relation is added to the + model before credentials etc have been provided. + ''' + # Do something before the relation is complete + pass + + def _on_identity_service_ready(self, event): + '''React to the IdentityService ready event. + + The IdentityService interface will use the provided config for the + request to the identity server. + ''' + # IdentityService Relation is ready. Do something with the completed relation. + pass + + def _on_identity_service_goneaway(self, event): + '''React to the IdentityService goneaway event. + + This event happens when an IdentityService relation is removed. + ''' + # IdentityService Relation has goneaway. shutdown services or suchlike + pass +``` +""" + +import json +import logging + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) +from ops.model import ( + Relation, + SecretNotFoundError, +) + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "0fa7fe7236c14c6e9624acf232b9a3b0" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 0 + + +logger = logging.getLogger(__name__) + + +class IdentityServiceConnectedEvent(EventBase): + """IdentityService connected Event.""" + + pass + + +class IdentityServiceReadyEvent(EventBase): + """IdentityService ready for use Event.""" + + pass + + +class IdentityServiceGoneAwayEvent(EventBase): + """IdentityService relation has gone-away Event""" + + pass + + +class IdentityServiceServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(IdentityServiceConnectedEvent) + ready = EventSource(IdentityServiceReadyEvent) + goneaway = EventSource(IdentityServiceGoneAwayEvent) + + +class IdentityServiceRequires(Object): + """ + IdentityServiceRequires class + """ + + on = IdentityServiceServerEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name: str, service_endpoints: dict, + region: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.service_endpoints = service_endpoints + self.region = region + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_service_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_service_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_identity_service_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_service_relation_broken, + ) + + def _on_identity_service_relation_joined(self, event): + """IdentityService relation joined.""" + logging.debug("IdentityService on_joined") + self.on.connected.emit() + self.register_services( + self.service_endpoints, + self.region) + + def _on_identity_service_relation_changed(self, event): + """IdentityService relation changed.""" + logging.debug("IdentityService on_changed") + try: + self.service_password + self.on.ready.emit() + except (AttributeError, KeyError): + pass + + def _on_identity_service_relation_broken(self, event): + """IdentityService relation broken.""" + logging.debug("IdentityService on_broken") + self.on.goneaway.emit() + + @property + def _identity_service_rel(self) -> Relation: + """The IdentityService relation.""" + return self.framework.model.get_relation(self.relation_name) + + def get_remote_app_data(self, key: str) -> str: + """Return the value for the given key from remote app data.""" + data = self._identity_service_rel.data[self._identity_service_rel.app] + return data.get(key) + + @property + def api_version(self) -> str: + """Return the api_version.""" + return self.get_remote_app_data('api-version') + + @property + def auth_host(self) -> str: + """Return the auth_host.""" + return self.get_remote_app_data('auth-host') + + @property + def auth_port(self) -> str: + """Return the auth_port.""" + return self.get_remote_app_data('auth-port') + + @property + def auth_protocol(self) -> str: + """Return the auth_protocol.""" + return self.get_remote_app_data('auth-protocol') + + @property + def internal_host(self) -> str: + """Return the internal_host.""" + return self.get_remote_app_data('internal-host') + + @property + def internal_port(self) -> str: + """Return the internal_port.""" + return self.get_remote_app_data('internal-port') + + @property + def internal_protocol(self) -> str: + """Return the internal_protocol.""" + return self.get_remote_app_data('internal-protocol') + + @property + def admin_domain_name(self) -> str: + """Return the admin_domain_name.""" + return self.get_remote_app_data('admin-domain-name') + + @property + def admin_domain_id(self) -> str: + """Return the admin_domain_id.""" + return self.get_remote_app_data('admin-domain-id') + + @property + def admin_project_name(self) -> str: + """Return the admin_project_name.""" + return self.get_remote_app_data('admin-project-name') + + @property + def admin_project_id(self) -> str: + """Return the admin_project_id.""" + return self.get_remote_app_data('admin-project-id') + + @property + def admin_user_name(self) -> str: + """Return the admin_user_name.""" + return self.get_remote_app_data('admin-user-name') + + @property + def admin_user_id(self) -> str: + """Return the admin_user_id.""" + return self.get_remote_app_data('admin-user-id') + + @property + def service_domain_name(self) -> str: + """Return the service_domain_name.""" + return self.get_remote_app_data('service-domain-name') + + @property + def service_domain_id(self) -> str: + """Return the service_domain_id.""" + return self.get_remote_app_data('service-domain-id') + + @property + def service_host(self) -> str: + """Return the service_host.""" + return self.get_remote_app_data('service-host') + + @property + def service_credentials(self) -> str: + """Return the service_credentials secret.""" + return self.get_remote_app_data('service-credentials') + + @property + def service_password(self) -> str: + """Return the service_password.""" + credentials_id = self.get_remote_app_data('service-credentials') + if not credentials_id: + return None + + try: + credentials = self.charm.model.get_secret(id=credentials_id) + return credentials.get_content().get("password") + except SecretNotFoundError: + logger.warning(f"Secret {credentials_id} not found") + return None + + @property + def service_port(self) -> str: + """Return the service_port.""" + return self.get_remote_app_data('service-port') + + @property + def service_protocol(self) -> str: + """Return the service_protocol.""" + return self.get_remote_app_data('service-protocol') + + @property + def service_project_name(self) -> str: + """Return the service_project_name.""" + return self.get_remote_app_data('service-project-name') + + @property + def service_project_id(self) -> str: + """Return the service_project_id.""" + return self.get_remote_app_data('service-project-id') + + @property + def service_user_name(self) -> str: + """Return the service_user_name.""" + credentials_id = self.get_remote_app_data('service-credentials') + if not credentials_id: + return None + + try: + credentials = self.charm.model.get_secret(id=credentials_id) + return credentials.get_content().get("username") + except SecretNotFoundError: + logger.warning(f"Secret {credentials_id} not found") + return None + + @property + def service_user_id(self) -> str: + """Return the service_user_id.""" + return self.get_remote_app_data('service-user-id') + + @property + def internal_auth_url(self) -> str: + """Return the internal_auth_url.""" + return self.get_remote_app_data('internal-auth-url') + + @property + def admin_auth_url(self) -> str: + """Return the admin_auth_url.""" + return self.get_remote_app_data('admin-auth-url') + + @property + def public_auth_url(self) -> str: + """Return the public_auth_url.""" + return self.get_remote_app_data('public-auth-url') + + def register_services(self, service_endpoints: dict, + region: str) -> None: + """Request access to the IdentityService server.""" + if self.model.unit.is_leader(): + logging.debug("Requesting service registration") + app_data = self._identity_service_rel.data[self.charm.app] + app_data["service-endpoints"] = json.dumps( + service_endpoints, sort_keys=True + ) + app_data["region"] = region + + +class HasIdentityServiceClientsEvent(EventBase): + """Has IdentityServiceClients Event.""" + + pass + + +class ReadyIdentityServiceClientsEvent(EventBase): + """IdentityServiceClients Ready Event.""" + + def __init__(self, handle, relation_id, relation_name, service_endpoints, + region, client_app_name): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.service_endpoints = service_endpoints + self.region = region + self.client_app_name = client_app_name + + def snapshot(self): + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "service_endpoints": self.service_endpoints, + "client_app_name": self.client_app_name, + "region": self.region} + + def restore(self, snapshot): + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.service_endpoints = snapshot["service_endpoints"] + self.region = snapshot["region"] + self.client_app_name = snapshot["client_app_name"] + + +class IdentityServiceClientEvents(ObjectEvents): + """Events class for `on`""" + + has_identity_service_clients = EventSource(HasIdentityServiceClientsEvent) + ready_identity_service_clients = EventSource(ReadyIdentityServiceClientsEvent) + + +class IdentityServiceProvides(Object): + """ + IdentityServiceProvides class + """ + + on = IdentityServiceClientEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_service_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_service_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_service_relation_broken, + ) + + def _on_identity_service_relation_joined(self, event): + """Handle IdentityService joined.""" + logging.debug("IdentityService on_joined") + self.on.has_identity_service_clients.emit() + + def _on_identity_service_relation_changed(self, event): + """Handle IdentityService changed.""" + logging.debug("IdentityService on_changed") + REQUIRED_KEYS = [ + 'service-endpoints', + 'region'] + + values = [ + event.relation.data[event.relation.app].get(k) + for k in REQUIRED_KEYS + ] + # Validate data on the relation + if all(values): + service_eps = json.loads( + event.relation.data[event.relation.app]['service-endpoints']) + self.on.ready_identity_service_clients.emit( + event.relation.id, + event.relation.name, + service_eps, + event.relation.data[event.relation.app]['region'], + event.relation.app.name) + + def _on_identity_service_relation_broken(self, event): + """Handle IdentityService broken.""" + logging.debug("IdentityServiceProvides on_departed") + # TODO clear data on the relation + + def set_identity_service_credentials(self, relation_name: int, + relation_id: str, + api_version: str, + auth_host: str, + auth_port: str, + auth_protocol: str, + internal_host: str, + internal_port: str, + internal_protocol: str, + service_host: str, + service_port: str, + service_protocol: str, + admin_domain: str, + admin_project: str, + admin_user: str, + service_domain: str, + service_project: str, + service_user: str, + internal_auth_url: str, + admin_auth_url: str, + public_auth_url: str, + service_credentials: str): + logging.debug("Setting identity_service connection information.") + _identity_service_rel = None + for relation in self.framework.model.relations[relation_name]: + if relation.id == relation_id: + _identity_service_rel = relation + if not _identity_service_rel: + # Relation has disappeared so skip send of data + return + app_data = _identity_service_rel.data[self.charm.app] + app_data["api-version"] = api_version + app_data["auth-host"] = auth_host + app_data["auth-port"] = str(auth_port) + app_data["auth-protocol"] = auth_protocol + app_data["internal-host"] = internal_host + app_data["internal-port"] = str(internal_port) + app_data["internal-protocol"] = internal_protocol + app_data["service-host"] = service_host + app_data["service-port"] = str(service_port) + app_data["service-protocol"] = service_protocol + app_data["admin-domain-name"] = admin_domain.name + app_data["admin-domain-id"] = admin_domain.id + app_data["admin-project-name"] = admin_project.name + app_data["admin-project-id"] = admin_project.id + app_data["admin-user-name"] = admin_user.name + app_data["admin-user-id"] = admin_user.id + app_data["service-domain-name"] = service_domain.name + app_data["service-domain-id"] = service_domain.id + app_data["service-project-name"] = service_project.name + app_data["service-project-id"] = service_project.id + app_data["service-user-id"] = service_user.id + app_data["internal-auth-url"] = internal_auth_url + app_data["admin-auth-url"] = admin_auth_url + app_data["public-auth-url"] = public_auth_url + app_data["service-credentials"] = service_credentials diff --git a/lib/charms/observability_libs/v0/kubernetes_service_patch.py b/lib/charms/observability_libs/v0/kubernetes_service_patch.py new file mode 100644 index 0000000..a3fb910 --- /dev/null +++ b/lib/charms/observability_libs/v0/kubernetes_service_patch.py @@ -0,0 +1,280 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +"""# KubernetesServicePatch Library. + +This library is designed to enable developers to more simply patch the Kubernetes Service created +by Juju during the deployment of a sidecar charm. When sidecar charms are deployed, Juju creates a +service named after the application in the namespace (named after the Juju model). This service by +default contains a "placeholder" port, which is 65536/TCP. + +When modifying the default set of resources managed by Juju, one must consider the lifecycle of the +charm. In this case, any modifications to the default service (created during deployment), will +be overwritten during a charm upgrade. + +When initialised, this library binds a handler to the parent charm's `install` and `upgrade_charm` +events which applies the patch to the cluster. This should ensure that the service ports are +correct throughout the charm's life. + +The constructor simply takes a reference to the parent charm, and a list of tuples that each define +a port for the service, where each tuple contains: + +- a name for the port +- port for the service to listen on +- optionally: a targetPort for the service (the port in the container!) +- optionally: a nodePort for the service (for NodePort or LoadBalancer services only!) +- optionally: a name of the service (in case service name needs to be patched as well) + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. **Note +that you also need to add `lightkube` and `lightkube-models` to your charm's `requirements.txt`.** + +```shell +cd some-charm +charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch +echo <<-EOF >> requirements.txt +lightkube +lightkube-models +EOF +``` + +Then, to initialise the library: + +For ClusterIP services: +```python +# ... +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.service_patcher = KubernetesServicePatch(self, [(f"{self.app.name}", 8080)]) + # ... +``` + +For LoadBalancer/NodePort services: +```python +# ... +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.service_patcher = KubernetesServicePatch( + self, [(f"{self.app.name}", 443, 443, 30666)], "LoadBalancer" + ) + # ... +``` + +Additionally, you may wish to use mocks in your charm's unit testing to ensure that the library +does not try to make any API calls, or open any files during testing that are unlikely to be +present, and could break your tests. The easiest way to do this is during your test `setUp`: + +```python +# ... + +@patch("charm.KubernetesServicePatch", lambda x, y: None) +def setUp(self, *unused): + self.harness = Harness(SomeCharm) + # ... +``` +""" + +import logging +from types import MethodType +from typing import Literal, Sequence, Tuple, Union + +from lightkube import ApiError, Client +from lightkube.models.core_v1 import ServicePort, ServiceSpec +from lightkube.models.meta_v1 import ObjectMeta +from lightkube.resources.core_v1 import Service +from lightkube.types import PatchType +from ops.charm import CharmBase +from ops.framework import Object + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "0042f86d0a874435adef581806cddbbb" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 6 + +PortDefinition = Union[Tuple[str, int], Tuple[str, int, int], Tuple[str, int, int, int]] +ServiceType = Literal["ClusterIP", "LoadBalancer"] + + +class KubernetesServicePatch(Object): + """A utility for patching the Kubernetes service set up by Juju.""" + + def __init__( + self, + charm: CharmBase, + ports: Sequence[PortDefinition], + service_name: str = None, + service_type: ServiceType = "ClusterIP", + additional_labels: dict = None, + additional_selectors: dict = None, + additional_annotations: dict = None, + ): + """Constructor for KubernetesServicePatch. + + Args: + charm: the charm that is instantiating the library. + ports: a list of tuples (name, port, targetPort, nodePort) for every service port. + service_name: allows setting custom name to the patched service. If none given, + application name will be used. + service_type: desired type of K8s service. Default value is in line with ServiceSpec's + default value. + additional_labels: Labels to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_selectors: Selectors to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_annotations: Annotations to be added to the kubernetes service. + """ + super().__init__(charm, "kubernetes-service-patch") + self.charm = charm + self.service_name = service_name if service_name else self._app + self.service = self._service_object( + ports, + service_name, + service_type, + additional_labels, + additional_selectors, + additional_annotations, + ) + + # Make mypy type checking happy that self._patch is a method + assert isinstance(self._patch, MethodType) + # Ensure this patch is applied during the 'install' and 'upgrade-charm' events + self.framework.observe(charm.on.install, self._patch) + self.framework.observe(charm.on.upgrade_charm, self._patch) + + def _service_object( + self, + ports: Sequence[PortDefinition], + service_name: str = None, + service_type: ServiceType = "ClusterIP", + additional_labels: dict = None, + additional_selectors: dict = None, + additional_annotations: dict = None, + ) -> Service: + """Creates a valid Service representation. + + Args: + ports: a list of tuples of the form (name, port) or (name, port, targetPort) + or (name, port, targetPort, nodePort) for every service port. If the 'targetPort' + is omitted, it is assumed to be equal to 'port', with the exception of NodePort + and LoadBalancer services, where all port numbers have to be specified. + service_name: allows setting custom name to the patched service. If none given, + application name will be used. + service_type: desired type of K8s service. Default value is in line with ServiceSpec's + default value. + additional_labels: Labels to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_selectors: Selectors to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_annotations: Annotations to be added to the kubernetes service. + + Returns: + Service: A valid representation of a Kubernetes Service with the correct ports. + """ + if not service_name: + service_name = self._app + labels = {"app.kubernetes.io/name": self._app} + if additional_labels: + labels.update(additional_labels) + selector = {"app.kubernetes.io/name": self._app} + if additional_selectors: + selector.update(additional_selectors) + return Service( + apiVersion="v1", + kind="Service", + metadata=ObjectMeta( + namespace=self._namespace, + name=service_name, + labels=labels, + annotations=additional_annotations, # type: ignore[arg-type] + ), + spec=ServiceSpec( + selector=selector, + ports=[ + ServicePort( + name=p[0], + port=p[1], + targetPort=p[2] if len(p) > 2 else p[1], # type: ignore[misc] + nodePort=p[3] if len(p) > 3 else None, # type: ignore[arg-type, misc] + ) + for p in ports + ], + type=service_type, + ), + ) + + def _patch(self, _) -> None: + """Patch the Kubernetes service created by Juju to map the correct port. + + Raises: + PatchFailed: if patching fails due to lack of permissions, or otherwise. + """ + if not self.charm.unit.is_leader(): + return + + client = Client() + try: + if self.service_name != self._app: + self._delete_and_create_service(client) + client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE) + except ApiError as e: + if e.status.code == 403: + logger.error("Kubernetes service patch failed: `juju trust` this application.") + else: + logger.error("Kubernetes service patch failed: %s", str(e)) + else: + logger.info("Kubernetes service '%s' patched successfully", self._app) + + def _delete_and_create_service(self, client: Client): + service = client.get(Service, self._app, namespace=self._namespace) + service.metadata.name = self.service_name # type: ignore[attr-defined] + service.metadata.resourceVersion = service.metadata.uid = None # type: ignore[attr-defined] # noqa: E501 + client.delete(Service, self._app, namespace=self._namespace) + client.create(service) + + def is_patched(self) -> bool: + """Reports if the service patch has been applied. + + Returns: + bool: A boolean indicating if the service patch has been applied. + """ + client = Client() + # Get the relevant service from the cluster + service = client.get(Service, name=self.service_name, namespace=self._namespace) + # Construct a list of expected ports, should the patch be applied + expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports] + # Construct a list in the same manner, using the fetched service + fetched_ports = [(p.port, p.targetPort) for p in service.spec.ports] # type: ignore[attr-defined] # noqa: E501 + return expected_ports == fetched_ports + + @property + def _app(self) -> str: + """Name of the current Juju application. + + Returns: + str: A string containing the name of the current Juju application. + """ + return self.charm.app.name + + @property + def _namespace(self) -> str: + """The Kubernetes namespace we're running in. + + Returns: + str: A string containing the name of the current Kubernetes namespace. + """ + with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f: + return f.read().strip() diff --git a/lib/charms/ovn_central_k8s/v0/ovsdb.py b/lib/charms/ovn_central_k8s/v0/ovsdb.py new file mode 100644 index 0000000..ef016e2 --- /dev/null +++ b/lib/charms/ovn_central_k8s/v0/ovsdb.py @@ -0,0 +1,218 @@ +"""TODO: Add a proper docstring here. + +This is a placeholder docstring for this charm library. Docstrings are +presented on Charmhub and updated whenever you push a new version of the +library. + +Complete documentation about creating and documenting libraries can be found +in the SDK docs at https://juju.is/docs/sdk/libraries. + +See `charmcraft publish-lib` and `charmcraft fetch-lib` for details of how to +share and consume charm libraries. They serve to enhance collaboration +between charmers. Use a charmer's libraries for classes that handle +integration with their charm. + +Bear in mind that new revisions of the different major API versions (v0, v1, +v2 etc) are maintained independently. You can continue to update v0 and v1 +after you have pushed v3. + +Markdown is supported, following the CommonMark specification. +""" + +import logging +import typing +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) + +# The unique Charmhub library identifier, never change it +LIBID = "114b7bb1970445daa61650e451f9da62" + +# 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 = 3 + + +# TODO: add your code here! Happy coding! +class OVSDBCMSConnectedEvent(EventBase): + """OVSDBCMS connected Event.""" + + pass + + +class OVSDBCMSReadyEvent(EventBase): + """OVSDBCMS ready for use Event.""" + + pass + + +class OVSDBCMSGoneAwayEvent(EventBase): + """OVSDBCMS relation has gone-away Event""" + + pass + + +class OVSDBCMSServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(OVSDBCMSConnectedEvent) + ready = EventSource(OVSDBCMSReadyEvent) + goneaway = EventSource(OVSDBCMSGoneAwayEvent) + + +class OVSDBCMSRequires(Object): + """ + OVSDBCMSRequires class + """ + + on = OVSDBCMSServerEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_ovsdb_cms_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_ovsdb_cms_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_ovsdb_cms_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_ovsdb_cms_relation_broken, + ) + + def _on_ovsdb_cms_relation_joined(self, event): + """OVSDBCMS relation joined.""" + logging.debug("OVSDBCMSRequires on_joined") + self.on.connected.emit() + + def bound_hostnames(self): + return self.get_all_unit_values("bound-hostname") + + def bound_addresses(self): + return self.get_all_unit_values("bound-address") + + def public_address(self): + relation = self.framework.model.get_relation(self.relation_name) + data = relation.data[relation.app] + return data.get('public-address') + + def remote_ready(self): + return all(self.bound_hostnames()) or all(self.bound_addresses()) + + def _on_ovsdb_cms_relation_changed(self, event): + """OVSDBCMS relation changed.""" + logging.debug("OVSDBCMSRequires on_changed") + if self.remote_ready(): + self.on.ready.emit() + + def _on_ovsdb_cms_relation_broken(self, event): + """OVSDBCMS relation broken.""" + logging.debug("OVSDBCMSRequires on_broken") + self.on.goneaway.emit() + + def get_all_unit_values(self, key: str) -> typing.List[str]: + """Retrieve value for key from all related units.""" + values = [] + relation = self.framework.model.get_relation(self.relation_name) + if relation: + for unit in relation.units: + values.append(relation.data[unit].get(key)) + return values + + + +class OVSDBCMSClientConnectedEvent(EventBase): + """OVSDBCMS connected Event.""" + + pass + + +class OVSDBCMSClientReadyEvent(EventBase): + """OVSDBCMS ready for use Event.""" + + pass + + +class OVSDBCMSClientGoneAwayEvent(EventBase): + """OVSDBCMS relation has gone-away Event""" + + pass + + +class OVSDBCMSClientEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(OVSDBCMSClientConnectedEvent) + ready = EventSource(OVSDBCMSClientReadyEvent) + goneaway = EventSource(OVSDBCMSClientGoneAwayEvent) + + +class OVSDBCMSProvides(Object): + """ + OVSDBCMSProvides class + """ + + on = OVSDBCMSClientEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_ovsdb_cms_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_ovsdb_cms_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_ovsdb_cms_relation_broken, + ) + + def _on_ovsdb_cms_relation_joined(self, event): + """Handle ovsdb-cms joined.""" + logging.debug("OVSDBCMSProvides on_joined") + self.on.connected.emit() + + def _on_ovsdb_cms_relation_changed(self, event): + """Handle ovsdb-cms changed.""" + logging.debug("OVSDBCMSProvides on_changed") + self.on.ready.emit() + + def _on_ovsdb_cms_relation_broken(self, event): + """Handle ovsdb-cms broken.""" + logging.debug("OVSDBCMSProvides on_departed") + self.on.goneaway.emit() + + def set_unit_data(self, settings: typing.Dict[str, str]) -> None: + """Publish settings on the peer unit data bag.""" + relations = self.framework.model.relations[self.relation_name] + for relation in relations: + for k, v in settings.items(): + relation.data[self.model.unit][k] = v + + def set_app_data(self, settings: typing.Dict[str, str]) -> None: + """Publish settings on the app data bag.""" + relations = self.framework.model.relations[self.relation_name] + for relation in relations: + for k, v in settings.items(): + relation.data[self.charm.app][k] = v diff --git a/lib/charms/rabbitmq_k8s/v0/rabbitmq.py b/lib/charms/rabbitmq_k8s/v0/rabbitmq.py new file mode 100644 index 0000000..c7df240 --- /dev/null +++ b/lib/charms/rabbitmq_k8s/v0/rabbitmq.py @@ -0,0 +1,286 @@ +"""RabbitMQProvides and Requires module. + +This library contains the Requires and Provides classes for handling +the rabbitmq interface. + +Import `RabbitMQRequires` in your charm, with the charm object and the +relation name: + - self + - "amqp" + +Also provide two additional parameters to the charm object: + - username + - vhost + +Two events are also available to respond to: + - connected + - ready + - goneaway + +A basic example showing the usage of this relation follows: + +``` +from charms.rabbitmq_k8s.v0.rabbitmq import RabbitMQRequires + +class RabbitMQClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # RabbitMQ Requires + self.amqp = RabbitMQRequires( + self, "amqp", + username="myusername", + vhost="vhostname" + ) + self.framework.observe( + self.amqp.on.connected, self._on_amqp_connected) + self.framework.observe( + self.amqp.on.ready, self._on_amqp_ready) + self.framework.observe( + self.amqp.on.goneaway, self._on_amqp_goneaway) + + def _on_amqp_connected(self, event): + '''React to the RabbitMQ connected event. + + This event happens when n RabbitMQ relation is added to the + model before credentials etc have been provided. + ''' + # Do something before the relation is complete + pass + + def _on_amqp_ready(self, event): + '''React to the RabbitMQ ready event. + + The RabbitMQ interface will use the provided username and vhost for the + request to the rabbitmq server. + ''' + # RabbitMQ Relation is ready. Do something with the completed relation. + pass + + def _on_amqp_goneaway(self, event): + '''React to the RabbitMQ goneaway event. + + This event happens when an RabbitMQ relation is removed. + ''' + # RabbitMQ Relation has goneaway. shutdown services or suchlike + pass +``` +""" + +# The unique Charmhub library identifier, never change it +LIBID = "45622352791142fd9cf87232e3bd6f2a" + +# 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 = 1 + +import logging + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) + +from ops.model import Relation + +from typing import List + +logger = logging.getLogger(__name__) + + +class RabbitMQConnectedEvent(EventBase): + """RabbitMQ connected Event.""" + + pass + + +class RabbitMQReadyEvent(EventBase): + """RabbitMQ ready for use Event.""" + + pass + + +class RabbitMQGoneAwayEvent(EventBase): + """RabbitMQ relation has gone-away Event""" + + pass + + +class RabbitMQServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(RabbitMQConnectedEvent) + ready = EventSource(RabbitMQReadyEvent) + goneaway = EventSource(RabbitMQGoneAwayEvent) + + +class RabbitMQRequires(Object): + """ + RabbitMQRequires class + """ + + on = RabbitMQServerEvents() + + def __init__(self, charm, relation_name: str, username: str, vhost: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.username = username + self.vhost = vhost + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_amqp_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_amqp_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_amqp_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_amqp_relation_broken, + ) + + def _on_amqp_relation_joined(self, event): + """RabbitMQ relation joined.""" + logging.debug("RabbitMQRabbitMQRequires on_joined") + self.on.connected.emit() + self.request_access(self.username, self.vhost) + + def _on_amqp_relation_changed(self, event): + """RabbitMQ relation changed.""" + logging.debug("RabbitMQRabbitMQRequires on_changed/departed") + if self.password: + self.on.ready.emit() + + def _on_amqp_relation_broken(self, event): + """RabbitMQ relation broken.""" + logging.debug("RabbitMQRabbitMQRequires on_broken") + self.on.goneaway.emit() + + @property + def _amqp_rel(self) -> Relation: + """The RabbitMQ relation.""" + return self.framework.model.get_relation(self.relation_name) + + @property + def password(self) -> str: + """Return the RabbitMQ password from the server side of the relation.""" + return self._amqp_rel.data[self._amqp_rel.app].get("password") + + @property + def hostname(self) -> str: + """Return the hostname from the RabbitMQ relation""" + return self._amqp_rel.data[self._amqp_rel.app].get("hostname") + + @property + def ssl_port(self) -> str: + """Return the SSL port from the RabbitMQ relation""" + return self._amqp_rel.data[self._amqp_rel.app].get("ssl_port") + + @property + def ssl_ca(self) -> str: + """Return the SSL port from the RabbitMQ relation""" + return self._amqp_rel.data[self._amqp_rel.app].get("ssl_ca") + + @property + def hostnames(self) -> List[str]: + """Return a list of remote RMQ hosts from the RabbitMQ relation""" + _hosts = [] + for unit in self._amqp_rel.units: + _hosts.append(self._amqp_rel.data[unit].get("ingress-address")) + return _hosts + + def request_access(self, username: str, vhost: str) -> None: + """Request access to the RabbitMQ server.""" + if self.model.unit.is_leader(): + logging.debug("Requesting RabbitMQ user and vhost") + self._amqp_rel.data[self.charm.app]["username"] = username + self._amqp_rel.data[self.charm.app]["vhost"] = vhost + + +class HasRabbitMQClientsEvent(EventBase): + """Has RabbitMQClients Event.""" + + pass + + +class ReadyRabbitMQClientsEvent(EventBase): + """RabbitMQClients Ready Event.""" + + pass + + +class RabbitMQClientEvents(ObjectEvents): + """Events class for `on`""" + + has_amqp_clients = EventSource(HasRabbitMQClientsEvent) + ready_amqp_clients = EventSource(ReadyRabbitMQClientsEvent) + + +class RabbitMQProvides(Object): + """ + RabbitMQProvides class + """ + + on = RabbitMQClientEvents() + + def __init__(self, charm, relation_name, callback): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.callback = callback + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_amqp_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_amqp_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_amqp_relation_broken, + ) + + def _on_amqp_relation_joined(self, event): + """Handle RabbitMQ joined.""" + logging.debug("RabbitMQRabbitMQProvides on_joined data={}" + .format(event.relation.data[event.relation.app])) + self.on.has_amqp_clients.emit() + + def _on_amqp_relation_changed(self, event): + """Handle RabbitMQ changed.""" + logging.debug("RabbitMQRabbitMQProvides on_changed data={}" + .format(event.relation.data[event.relation.app])) + # Validate data on the relation + if self.username(event) and self.vhost(event): + self.on.ready_amqp_clients.emit() + if self.charm.unit.is_leader(): + self.callback(event, self.username(event), self.vhost(event)) + else: + logging.warning("Received RabbitMQ changed event without the " + "expected keys ('username', 'vhost') in the " + "application data bag. Incompatible charm in " + "other end of relation?") + + def _on_amqp_relation_broken(self, event): + """Handle RabbitMQ broken.""" + logging.debug("RabbitMQRabbitMQProvides on_departed") + # TODO clear data on the relation + + def username(self, event): + """Return the RabbitMQ username from the client side of the relation.""" + return event.relation.data[event.relation.app].get("username") + + def vhost(self, event): + """Return the RabbitMQ vhost from the client side of the relation.""" + return event.relation.data[event.relation.app].get("vhost") diff --git a/lib/charms/tls_certificates_interface/v1/tls_certificates.py b/lib/charms/tls_certificates_interface/v1/tls_certificates.py new file mode 100644 index 0000000..1eda19b --- /dev/null +++ b/lib/charms/tls_certificates_interface/v1/tls_certificates.py @@ -0,0 +1,1261 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the tls-certificates relation. + +This library contains the Requires and Provides classes for handling the tls-certificates +interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.tls_certificates_interface.v1.tls_certificates +``` + +Add the following libraries to the charm's `requirements.txt` file: +- jsonschema +- cryptography + +Add the following section to the charm's `charmcraft.yaml` file: +```yaml +parts: + charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo +``` + +### Provider charm +The provider charm is the charm providing certificates to another charm that requires them. In +this example, the provider charm is storing its private key using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateCreationRequestEvent, + CertificateRevocationRequestEvent, + TLSCertificatesProvidesV1, + generate_private_key, +) +from ops.charm import CharmBase, InstallEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +def generate_ca(private_key: bytes, subject: str) -> str: + return "whatever ca content" + + +def generate_certificate(ca: str, private_key: str, csr: str) -> str: + return "Whatever certificate" + + +class ExampleProviderCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.certificates = TLSCertificatesProvidesV1(self, "certificates") + self.framework.observe( + self.certificates.on.certificate_request, self._on_certificate_request + ) + self.framework.observe( + self.certificates.on.certificate_revoked, self._on_certificate_revocation_request + ) + self.framework.observe(self.on.install, self._on_install) + + def _on_install(self, event: InstallEvent) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + ca_certificate = generate_ca(private_key=private_key, subject="whatever") + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + { + "private_key_password": "banana", + "private_key": private_key, + "ca_certificate": ca_certificate, + } + ) + self.unit.status = ActiveStatus() + + def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + ca_certificate = replicas_relation.data[self.app].get("ca_certificate") + private_key = replicas_relation.data[self.app].get("private_key") + certificate = generate_certificate( + ca=ca_certificate, + private_key=private_key, + csr=event.certificate_signing_request, + ) + + self.certificates.set_relation_certificate( + certificate=certificate, + certificate_signing_request=event.certificate_signing_request, + ca=ca_certificate, + chain=[ca_certificate, certificate], + relation_id=event.relation_id, + ) + + def _on_certificate_revocation_request(self, event: CertificateRevocationRequestEvent) -> None: + # Do what you want to do with this information + pass + + +if __name__ == "__main__": + main(ExampleProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. In +this example, the requirer charm is storing its certificates using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + TLSCertificatesRequiresV1, + generate_csr, + generate_private_key, +) +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +class ExampleRequirerCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.cert_subject = "whatever" + self.certificates = TLSCertificatesRequiresV1(self, "certificates") + self.framework.observe(self.on.install, self._on_install) + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + self.framework.observe( + self.certificates.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.certificates.on.certificate_expiring, self._on_certificate_expiring + ) + + def _on_install(self, event) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + {"private_key_password": "banana", "private_key": private_key.decode()} + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + replicas_relation.data[self.app].update({"csr": csr.decode()}) + self.certificates.request_certificate_creation(certificate_signing_request=csr) + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update({"certificate": event.certificate}) + replicas_relation.data[self.app].update({"ca": event.ca}) + replicas_relation.data[self.app].update({"chain": event.chain}) + self.unit.status = ActiveStatus() + + def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + old_csr = replicas_relation.data[self.app].get("csr") + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + + +if __name__ == "__main__": + main(ExampleRequirerCharm) +``` +""" # noqa: D405, D410, D411, D214, D416 + +import copy +import json +import logging +import uuid +from datetime import datetime, timedelta +from ipaddress import IPv4Address +from typing import Dict, List, Optional + +from cryptography import x509 +from cryptography.hazmat._oid import ExtensionOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.extensions import Extension, ExtensionNotFound +from jsonschema import exceptions, validate # type: ignore[import] +from ops.charm import CharmBase, CharmEvents, RelationChangedEvent, UpdateStatusEvent +from ops.framework import EventBase, EventSource, Handle, Object + +# The unique Charmhub library identifier, never change it +LIBID = "afd8c2bccf834997afce12c2706d2ede" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 10 + +REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/requirer.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` requirer root schema", + "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 + "examples": [ + { + "certificate_signing_requests": [ + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + ] + } + ], + "properties": { + "certificate_signing_requests": { + "type": "array", + "items": { + "type": "object", + "properties": {"certificate_signing_request": {"type": "string"}}, + "required": ["certificate_signing_request"], + }, + } + }, + "required": ["certificate_signing_requests"], + "additionalProperties": True, +} + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/provider.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` provider root schema", + "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 + "example": [ + { + "certificates": [ + { + "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": [ + "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 + ], + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 + "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 + } + ] + } + ], + "properties": { + "certificates": { + "$id": "#/properties/certificates", + "type": "array", + "items": { + "$id": "#/properties/certificates/items", + "type": "object", + "required": ["certificate_signing_request", "certificate", "ca", "chain"], + "properties": { + "certificate_signing_request": { + "$id": "#/properties/certificates/items/certificate_signing_request", + "type": "string", + }, + "certificate": { + "$id": "#/properties/certificates/items/certificate", + "type": "string", + }, + "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, + "chain": { + "$id": "#/properties/certificates/items/chain", + "type": "array", + "items": { + "type": "string", + "$id": "#/properties/certificates/items/chain/items", + }, + }, + }, + "additionalProperties": True, + }, + } + }, + "required": ["certificates"], + "additionalProperties": True, +} + + +logger = logging.getLogger(__name__) + + +class CertificateAvailableEvent(EventBase): + """Charm Event triggered when a TLS certificate is available.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class CertificateExpiringEvent(EventBase): + """Charm Event triggered when a TLS certificate is almost expired.""" + + def __init__(self, handle, certificate: str, expiry: str): + """CertificateExpiringEvent. + + Args: + handle (Handle): Juju framework handle + certificate (str): TLS Certificate + expiry (str): Datetime string reprensenting the time at which the certificate + won't be valid anymore. + """ + super().__init__(handle) + self.certificate = certificate + self.expiry = expiry + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate, "expiry": self.expiry} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.expiry = snapshot["expiry"] + + +class CertificateExpiredEvent(EventBase): + """Charm Event triggered when a TLS certificate is expired.""" + + def __init__(self, handle: Handle, certificate: str): + super().__init__(handle) + self.certificate = certificate + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + + +class CertificateCreationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate is required.""" + + def __init__(self, handle: Handle, certificate_signing_request: str, relation_id: int): + super().__init__(handle) + self.certificate_signing_request = certificate_signing_request + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate_signing_request": self.certificate_signing_request, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.relation_id = snapshot["relation_id"] + + +class CertificateRevocationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate needs to be revoked.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +def _load_relation_data(raw_relation_data: dict) -> dict: + """Loads relation data from the relation data bag. + + Json loads all data. + + Args: + raw_relation_data: Relation data from the databag + + Returns: + dict: Relation data in dict format. + """ + certificate_data = dict() + for key in raw_relation_data: + try: + certificate_data[key] = json.loads(raw_relation_data[key]) + except (json.decoder.JSONDecodeError, TypeError): + certificate_data[key] = raw_relation_data[key] + return certificate_data + + +def generate_ca( + private_key: bytes, + subject: str, + private_key_password: Optional[bytes] = None, + validity: int = 365, + country: str = "US", +) -> bytes: + """Generates a CA Certificate. + + Args: + private_key (bytes): Private key + subject (str): Certificate subject + private_key_password (bytes): Private key password + validity (int): Certificate validity time (in days) + country (str): Certificate Issuing country + + Returns: + bytes: CA Certificate. + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), + x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), + ] + ) + subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( + private_key_object.public_key() # type: ignore[arg-type] + ) + subject_identifier = key_identifier = subject_identifier_object.public_bytes() + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key_object.public_key()) # type: ignore[arg-type] + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) + .add_extension( + x509.AuthorityKeyIdentifier( + key_identifier=key_identifier, + authority_cert_issuer=None, + authority_cert_serial_number=None, + ), + critical=False, + ) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] + ) + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_certificate( + csr: bytes, + ca: bytes, + ca_key: bytes, + ca_key_password: Optional[bytes] = None, + validity: int = 365, + alt_names: List[str] = None, +) -> bytes: + """Generates a TLS certificate based on a CSR. + + Args: + csr (bytes): CSR + ca (bytes): CA Certificate + ca_key (bytes): CA private key + ca_key_password: CA private key password + validity (int): Certificate validity (in days) + alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR + + Returns: + bytes: Certificate + """ + csr_object = x509.load_pem_x509_csr(csr) + subject = csr_object.subject + issuer = x509.load_pem_x509_certificate(ca).issuer + private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) + + certificate_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(csr_object.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + ) + + extensions_list = csr_object.extensions + san_ext: Optional[x509.Extension] = None + if alt_names: + full_sans_dns = alt_names.copy() + try: + loaded_san_ext = csr_object.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ) + full_sans_dns.extend(loaded_san_ext.value.get_values_for_type(x509.DNSName)) + except ExtensionNotFound: + pass + finally: + san_ext = Extension( + ExtensionOID.SUBJECT_ALTERNATIVE_NAME, + False, + x509.SubjectAlternativeName([x509.DNSName(name) for name in full_sans_dns]), + ) + if not extensions_list: + extensions_list = x509.Extensions([san_ext]) + + for extension in extensions_list: + if extension.value.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME and san_ext: + extension = san_ext + + certificate_builder = certificate_builder.add_extension( + extension.value, + critical=extension.critical, + ) + certificate_builder._version = x509.Version.v3 + cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_pfx_package( + certificate: bytes, + private_key: bytes, + package_password: str, + private_key_password: Optional[bytes] = None, +) -> bytes: + """Generates a PFX package to contain the TLS certificate and private key. + + Args: + certificate (bytes): TLS certificate + private_key (bytes): Private key + package_password (str): Password to open the PFX package + private_key_password (bytes): Private key password + + Returns: + bytes: + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + certificate_object = x509.load_pem_x509_certificate(certificate) + name = certificate_object.subject.rfc4514_string() + pfx_bytes = pkcs12.serialize_key_and_certificates( + name=name.encode(), + cert=certificate_object, + key=private_key_object, # type: ignore[arg-type] + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), + ) + return pfx_bytes + + +def generate_private_key( + password: Optional[bytes] = None, + key_size: int = 2048, + public_exponent: int = 65537, +) -> bytes: + """Generates a private key. + + Args: + password (bytes): Password for decrypting the private key + key_size (int): Key size in bytes + public_exponent: Public exponent. + + Returns: + bytes: Private Key + """ + private_key = rsa.generate_private_key( + public_exponent=public_exponent, + key_size=key_size, + ) + key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption(password) + if password + else serialization.NoEncryption(), + ) + return key_bytes + + +def generate_csr( + private_key: bytes, + subject: str, + add_unique_id_to_subject_name: bool = True, + organization: str = None, + email_address: str = None, + country_name: str = None, + private_key_password: Optional[bytes] = None, + sans: Optional[List[str]] = None, + sans_oid: Optional[List[str]] = None, + sans_ip: Optional[List[str]] = None, + sans_dns: Optional[List[str]] = None, + additional_critical_extensions: Optional[List] = None, +) -> bytes: + """Generates a CSR using private key and subject. + + Args: + private_key (bytes): Private key + subject (str): CSR Subject. + add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's + subject name. Always leave to "True" when the CSR is used to request certificates + using the tls-certificates relation. + organization (str): Name of organization. + email_address (str): Email address. + country_name (str): Country Name. + private_key_password (bytes): Private key password + sans (list): Use sans_dns - this will be deprecated in a future release + List of DNS subject alternative names (keeping it for now for backward compatibility) + sans_oid (list): List of registered ID SANs + sans_dns (list): List of DNS subject alternative names (similar to the arg: sans) + sans_ip (list): List of IP subject alternative names + additional_critical_extensions (list): List if critical additional extension objects. + Object must be a x509 ExtensionType. + + Returns: + bytes: CSR + """ + signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) + subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] + if add_unique_id_to_subject_name: + unique_identifier = uuid.uuid4() + subject_name.append( + x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) + ) + if organization: + subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) + if email_address: + subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) + if country_name: + subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) + csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) + + _sans: List[x509.GeneralName] = [] + if sans_oid: + _sans.extend([x509.RegisteredID(x509.ObjectIdentifier(san)) for san in sans_oid]) + if sans_ip: + _sans.extend([x509.IPAddress(IPv4Address(san)) for san in sans_ip]) + if sans: + _sans.extend([x509.DNSName(san) for san in sans]) + if sans_dns: + _sans.extend([x509.DNSName(san) for san in sans_dns]) + if _sans: + csr = csr.add_extension(x509.SubjectAlternativeName(set(_sans)), critical=False) + + if additional_critical_extensions: + for extension in additional_critical_extensions: + csr = csr.add_extension(extension, critical=True) + + signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] + return signed_certificate.public_bytes(serialization.Encoding.PEM) + + +class CertificatesProviderCharmEvents(CharmEvents): + """List of events that the TLS Certificates provider charm can leverage.""" + + certificate_creation_request = EventSource(CertificateCreationRequestEvent) + certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) + + +class CertificatesRequirerCharmEvents(CharmEvents): + """List of events that the TLS Certificates requirer charm can leverage.""" + + certificate_available = EventSource(CertificateAvailableEvent) + certificate_expiring = EventSource(CertificateExpiringEvent) + certificate_expired = EventSource(CertificateExpiredEvent) + + +class TLSCertificatesProvidesV1(Object): + """TLS certificates provider class to be instantiated by TLS certificates providers.""" + + on = CertificatesProviderCharmEvents() + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.charm = charm + self.relationship_name = relationship_name + + def _add_certificate( + self, + relation_id: int, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + ) -> None: + """Adds certificate to relation data. + + Args: + relation_id (int): Relation id + certificate (str): Certificate + certificate_signing_request (str): Certificate Signing Request + ca (str): CA Certificate + chain (list): CA Chain + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_certificate = { + "certificate": certificate, + "certificate_signing_request": certificate_signing_request, + "ca": ca, + "chain": chain, + } + provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + provider_certificates = provider_relation_data.get("certificates", []) + certificates = copy.deepcopy(provider_certificates) + if new_certificate in certificates: + logger.info("Certificate already in relation data - Doing nothing") + return + certificates.append(new_certificate) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + def _remove_certificate( + self, + relation_id: int, + certificate: str = None, + certificate_signing_request: str = None, + ) -> None: + """Removes certificate from a given relation based on user provided certificate or csr. + + Args: + relation_id (int): Relation id + certificate (str): Certificate (optional) + certificate_signing_request: Certificate signing request (optional) + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} with relation id {relation_id} does not exist" + ) + provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + provider_certificates = provider_relation_data.get("certificates", []) + certificates = copy.deepcopy(provider_certificates) + for certificate_dict in certificates: + if certificate and certificate_dict["certificate"] == certificate: + certificates.remove(certificate_dict) + if ( + certificate_signing_request + and certificate_dict["certificate_signing_request"] == certificate_signing_request + ): + certificates.remove(certificate_dict) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Uses JSON schema validator to validate relation data content. + + Args: + certificates_data (dict): Certificate data dictionary as retrieved from relation data. + + Returns: + bool: True/False depending on whether the relation data follows the json schema. + """ + try: + validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def revoke_all_certificates(self) -> None: + """Revokes all certificates of this provider. + + This method is meant to be used when the Root CA has changed. + """ + for relation in self.model.relations[self.relationship_name]: + relation.data[self.model.app]["certificates"] = json.dumps([]) + + def set_relation_certificate( + self, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + relation_id: int, + ) -> None: + """Adds certificates to relation data. + + Args: + certificate (str): Certificate + certificate_signing_request (str): Certificate signing request + ca (str): CA Certificate + chain (list): CA Chain + relation_id (int): Juju relation ID + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + self._remove_certificate( + certificate_signing_request=certificate_signing_request.strip(), + relation_id=relation_id, + ) + self._add_certificate( + relation_id=relation_id, + certificate=certificate.strip(), + certificate_signing_request=certificate_signing_request.strip(), + ca=ca.strip(), + chain=[cert.strip() for cert in chain], + ) + + def remove_certificate(self, certificate: str) -> None: + """Removes a given certificate from relation data. + + Args: + certificate (str): TLS Certificate + + Returns: + None + """ + certificates_relation = self.model.relations[self.relationship_name] + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + for certificate_relation in certificates_relation: + self._remove_certificate(certificate=certificate, relation_id=certificate_relation.id) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggerred on relation changed event. + + Looks at the relation data and either emits: + - certificate request event: If the unit relation data contains a CSR for which + a certificate does not exist in the provider relation data. + - certificate revocation event: If the provider relation data contains a CSR for which + a csr does not exist in the requirer relation data. + + Args: + event: Juju event + + Returns: + None + """ + assert event.unit is not None + requirer_relation_data = _load_relation_data(event.relation.data[event.unit]) + provider_relation_data = _load_relation_data(event.relation.data[self.charm.app]) + if not self._relation_data_is_valid(requirer_relation_data): + logger.warning( + f"Relation data did not pass JSON Schema validation: {requirer_relation_data}" + ) + return + provider_certificates = provider_relation_data.get("certificates", []) + requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) + provider_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in provider_certificates + ] + requirer_unit_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in requirer_csrs + ] + for certificate_signing_request in requirer_unit_csrs: + if certificate_signing_request not in provider_csrs: + self.on.certificate_creation_request.emit( + certificate_signing_request=certificate_signing_request, + relation_id=event.relation.id, + ) + self._revoke_certificates_for_which_no_csr_exists(relation_id=event.relation.id) + + def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None: + """Revokes certificates for which no unit has a CSR. + + Goes through all generated certificates and compare agains the list of CSRS for all units + of a given relationship. + + Args: + relation_id (int): Relation id + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + provider_relation_data = _load_relation_data(certificates_relation.data[self.charm.app]) + list_of_csrs: List[str] = [] + for unit in certificates_relation.units: + requirer_relation_data = _load_relation_data(certificates_relation.data[unit]) + requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) + list_of_csrs.extend(csr["certificate_signing_request"] for csr in requirer_csrs) + provider_certificates = provider_relation_data.get("certificates", []) + for certificate in provider_certificates: + if certificate["certificate_signing_request"] not in list_of_csrs: + self.on.certificate_revocation_request.emit( + certificate=certificate["certificate"], + certificate_signing_request=certificate["certificate_signing_request"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + self.remove_certificate(certificate=certificate["certificate"]) + + +class TLSCertificatesRequiresV1(Object): + """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" + + on = CertificatesRequirerCharmEvents() + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + expiry_notification_time: int = 168, + ): + """Generates/use private key and observes relation changed event. + + Args: + charm: Charm object + relationship_name: Juju relation name + expiry_notification_time (int): Time difference between now and expiry (in hours). + Used to trigger the CertificateExpiring event. Default: 7 days. + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.expiry_notification_time = expiry_notification_time + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe(charm.on.update_status, self._on_update_status) + + @property + def _requirer_csrs(self) -> List[Dict[str, str]]: + """Returns list of requirer CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + requirer_relation_data = _load_relation_data(relation.data[self.model.unit]) + return requirer_relation_data.get("certificate_signing_requests", []) + + @property + def _provider_certificates(self) -> List[Dict[str, str]]: + """Returns list of provider CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + if not relation.app: + raise RuntimeError(f"Remote app for relation {self.relationship_name} does not exist") + provider_relation_data = _load_relation_data(relation.data[relation.app]) + return provider_relation_data.get("certificates", []) + + def _add_requirer_csr(self, csr: str) -> None: + """Adds CSR to relation data. + + Args: + csr (str): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_csr_dict = {"certificate_signing_request": csr} + if new_csr_dict in self._requirer_csrs: + logger.info("CSR already in relation data - Doing nothing") + return + requirer_csrs = copy.deepcopy(self._requirer_csrs) + requirer_csrs.append(new_csr_dict) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def _remove_requirer_csr(self, csr: str) -> None: + """Removes CSR from relation data. + + Args: + csr (str): Certificate signing request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + requirer_csrs = copy.deepcopy(self._requirer_csrs) + csr_dict = {"certificate_signing_request": csr} + if csr_dict not in requirer_csrs: + logger.info("CSR not in relation data - Doing nothing") + return + requirer_csrs.remove(csr_dict) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def request_certificate_creation(self, certificate_signing_request: bytes) -> None: + """Request TLS certificate to provider charm. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + message = ( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + logger.error(message) + raise RuntimeError(message) + self._add_requirer_csr(certificate_signing_request.decode().strip()) + logger.info("Certificate request sent to provider") + + def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: + """Removes CSR from relation data. + + The provider of this relation is then expected to remove certificates associated to this + CSR from the relation data as well and emit a request_certificate_revocation event for the + provider charm to interpret. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + self._remove_requirer_csr(certificate_signing_request.decode().strip()) + logger.info("Certificate revocation sent to provider") + + def request_certificate_renewal( + self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes + ) -> None: + """Renews certificate. + + Removes old CSR from relation data and adds new one. + + Args: + old_certificate_signing_request: Old CSR + new_certificate_signing_request: New CSR + + Returns: + None + """ + try: + self.request_certificate_revocation( + certificate_signing_request=old_certificate_signing_request + ) + except RuntimeError: + logger.warning("Certificate revocation failed.") + self.request_certificate_creation( + certificate_signing_request=new_certificate_signing_request + ) + logger.info("Certificate renewal request completed.") + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Checks whether relation data is valid based on json schema. + + Args: + certificates_data: Certificate data in dict format. + + Returns: + bool: Whether relation data is valid. + """ + try: + validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggerred on relation changed events. + + Args: + event: Juju event + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + logger.warning(f"No relation: {self.relationship_name}") + return + if not relation.app: + logger.warning(f"No remote app in relation: {self.relationship_name}") + return + provider_relation_data = _load_relation_data(relation.data[relation.app]) + if not self._relation_data_is_valid(provider_relation_data): + logger.warning( + f"Provider relation data did not pass JSON Schema validation: " + f"{event.relation.data[relation.app]}" + ) + return + requirer_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in self._requirer_csrs + ] + for certificate in self._provider_certificates: + if certificate["certificate_signing_request"] in requirer_csrs: + self.on.certificate_available.emit( + certificate_signing_request=certificate["certificate_signing_request"], + certificate=certificate["certificate"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Triggered on update status event. + + Goes through each certificate in the "certificates" relation and checks their expiry date. + If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if + they are expired, emits a CertificateExpiredEvent. + + Args: + event (UpdateStatusEvent): Juju event + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + logger.warning(f"No relation: {self.relationship_name}") + return + if not relation.app: + logger.warning(f"No remote app in relation: {self.relationship_name}") + return + provider_relation_data = _load_relation_data(relation.data[relation.app]) + if not self._relation_data_is_valid(provider_relation_data): + logger.warning( + f"Provider relation data did not pass JSON Schema validation: " + f"{relation.data[relation.app]}" + ) + return + for certificate_dict in self._provider_certificates: + certificate = certificate_dict["certificate"] + try: + certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) + except ValueError: + logger.warning("Could not load certificate.") + continue + time_difference = certificate_object.not_valid_after - datetime.utcnow() + if time_difference.total_seconds() < 0: + logger.warning("Certificate is expired") + self.on.certificate_expired.emit(certificate=certificate) + self.request_certificate_revocation(certificate.encode()) + continue + if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): + logger.warning("Certificate almost expired") + self.on.certificate_expiring.emit( + certificate=certificate, expiry=certificate_object.not_valid_after.isoformat() + ) diff --git a/lib/charms/traefik_k8s/v1/ingress.py b/lib/charms/traefik_k8s/v1/ingress.py new file mode 100644 index 0000000..e1769e8 --- /dev/null +++ b/lib/charms/traefik_k8s/v1/ingress.py @@ -0,0 +1,558 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +r"""# Interface Library for ingress. + +This library wraps relation endpoints using the `ingress` interface +and provides a Python API for both requesting and providing per-application +ingress, with load-balancing occurring across all units. + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. + +```shell +cd some-charm +charmcraft fetch-lib charms.traefik_k8s.v1.ingress +``` + +In the `metadata.yaml` of the charm, add the following: + +```yaml +requires: + ingress: + interface: ingress + limit: 1 +``` + +Then, to initialise the library: + +```python +from charms.traefik_k8s.v1.ingress import (IngressPerAppRequirer, + IngressPerAppReadyEvent, IngressPerAppRevokedEvent) + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.ingress = IngressPerAppRequirer(self, port=80) + # The following event is triggered when the ingress URL to be used + # by this deployment of the `SomeCharm` is ready (or changes). + self.framework.observe( + self.ingress.on.ready, self._on_ingress_ready + ) + self.framework.observe( + self.ingress.on.revoked, self._on_ingress_revoked + ) + + def _on_ingress_ready(self, event: IngressPerAppReadyEvent): + logger.info("This app's ingress URL: %s", event.url) + + def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): + logger.info("This app no longer has ingress") +""" + +import logging +import socket +import typing +from typing import Any, Dict, Optional, Tuple, Union + +import yaml +from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent +from ops.framework import EventSource, Object, ObjectEvents, StoredState +from ops.model import ModelError, Relation + +# The unique Charmhub library identifier, never change it +LIBID = "e6de2a5cd5b34422a204668f3b8f90d2" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 5 + +DEFAULT_RELATION_NAME = "ingress" +RELATION_INTERFACE = "ingress" + +log = logging.getLogger(__name__) + +try: + import jsonschema + + DO_VALIDATION = True +except ModuleNotFoundError: + log.warning( + "The `ingress` library needs the `jsonschema` package to be able " + "to do runtime data validation; without it, it will still work but validation " + "will be disabled. \n" + "It is recommended to add `jsonschema` to the 'requirements.txt' of your charm, " + "which will enable this feature." + ) + DO_VALIDATION = False + +INGRESS_REQUIRES_APP_SCHEMA = { + "type": "object", + "properties": { + "model": {"type": "string"}, + "name": {"type": "string"}, + "host": {"type": "string"}, + "port": {"type": "string"}, + "strip-prefix": {"type": "string"}, + }, + "required": ["model", "name", "host", "port"], +} + +INGRESS_PROVIDES_APP_SCHEMA = { + "type": "object", + "properties": { + "ingress": {"type": "object", "properties": {"url": {"type": "string"}}}, + }, + "required": ["ingress"], +} + +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict # py35 compat + +# Model of the data a unit implementing the requirer will need to provide. +RequirerData = TypedDict( + "RequirerData", + {"model": str, "name": str, "host": str, "port": int, "strip-prefix": bool}, + total=False, +) +# Provider ingress data model. +ProviderIngressData = TypedDict("ProviderIngressData", {"url": str}) +# Provider application databag model. +ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData}) + + +def _validate_data(data, schema): + """Checks whether `data` matches `schema`. + + Will raise DataValidationError if the data is not valid, else return None. + """ + if not DO_VALIDATION: + return + try: + jsonschema.validate(instance=data, schema=schema) + except jsonschema.ValidationError as e: + raise DataValidationError(data, schema) from e + + +class DataValidationError(RuntimeError): + """Raised when data validation fails on IPU relation data.""" + + +class _IngressPerAppBase(Object): + """Base class for IngressPerUnit interface classes.""" + + def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): + super().__init__(charm, relation_name) + + self.charm: CharmBase = charm + self.relation_name = relation_name + self.app = self.charm.app + self.unit = self.charm.unit + + observe = self.framework.observe + rel_events = charm.on[relation_name] + observe(rel_events.relation_created, self._handle_relation) + observe(rel_events.relation_joined, self._handle_relation) + observe(rel_events.relation_changed, self._handle_relation) + observe(rel_events.relation_broken, self._handle_relation_broken) + observe(charm.on.leader_elected, self._handle_upgrade_or_leader) + observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) + + @property + def relations(self): + """The list of Relation instances associated with this endpoint.""" + return list(self.charm.model.relations[self.relation_name]) + + def _handle_relation(self, event): + """Subclasses should implement this method to handle a relation update.""" + pass + + def _handle_relation_broken(self, event): + """Subclasses should implement this method to handle a relation breaking.""" + pass + + def _handle_upgrade_or_leader(self, event): + """Subclasses should implement this method to handle upgrades or leadership change.""" + pass + + +class _IPAEvent(RelationEvent): + __args__ = () # type: Tuple[str, ...] + __optional_kwargs__ = {} # type: Dict[str, Any] + + @classmethod + def __attrs__(cls): + return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) + + def __init__(self, handle, relation, *args, **kwargs): + super().__init__(handle, relation) + + if not len(self.__args__) == len(args): + raise TypeError("expected {} args, got {}".format(len(self.__args__), len(args))) + + for attr, obj in zip(self.__args__, args): + setattr(self, attr, obj) + for attr, default in self.__optional_kwargs__.items(): + obj = kwargs.get(attr, default) + setattr(self, attr, obj) + + def snapshot(self) -> dict: + dct = super().snapshot() + for attr in self.__attrs__(): + obj = getattr(self, attr) + try: + dct[attr] = obj + except ValueError as e: + raise ValueError( + "cannot automagically serialize {}: " + "override this method and do it " + "manually.".format(obj) + ) from e + + return dct + + def restore(self, snapshot: dict) -> None: + super().restore(snapshot) + for attr, obj in snapshot.items(): + setattr(self, attr, obj) + + +class IngressPerAppDataProvidedEvent(_IPAEvent): + """Event representing that ingress data has been provided for an app.""" + + __args__ = ("name", "model", "port", "host", "strip_prefix") + + if typing.TYPE_CHECKING: + name = None # type: str + model = None # type: str + port = None # type: int + host = None # type: str + strip_prefix = False # type: bool + + +class IngressPerAppDataRemovedEvent(RelationEvent): + """Event representing that ingress data has been removed for an app.""" + + +class IngressPerAppProviderEvents(ObjectEvents): + """Container for IPA Provider events.""" + + data_provided = EventSource(IngressPerAppDataProvidedEvent) + data_removed = EventSource(IngressPerAppDataRemovedEvent) + + +class IngressPerAppProvider(_IngressPerAppBase): + """Implementation of the provider of ingress.""" + + on = IngressPerAppProviderEvents() + + def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): + """Constructor for IngressPerAppProvider. + + Args: + charm: The charm that is instantiating the instance. + relation_name: The name of the relation endpoint to bind to + (defaults to "ingress"). + """ + super().__init__(charm, relation_name) + + def _handle_relation(self, event): + # created, joined or changed: if remote side has sent the required data: + # notify listeners. + if self.is_ready(event.relation): + data = self._get_requirer_data(event.relation) + self.on.data_provided.emit( + event.relation, + data["name"], + data["model"], + data["port"], + data["host"], + data.get("strip-prefix", False), + ) + + def _handle_relation_broken(self, event): + self.on.data_removed.emit(event.relation) + + def wipe_ingress_data(self, relation: Relation): + """Clear ingress data from relation.""" + assert self.unit.is_leader(), "only leaders can do this" + try: + relation.data + except ModelError as e: + log.warning( + "error {} accessing relation data for {!r}. " + "Probably a ghost of a dead relation is still " + "lingering around.".format(e, relation.name) + ) + return + del relation.data[self.app]["ingress"] + + def _get_requirer_data(self, relation: Relation) -> RequirerData: + """Fetch and validate the requirer's app databag. + + For convenience, we convert 'port' to integer. + """ + if not all((relation.app, relation.app.name)): + # Handle edge case where remote app name can be missing, e.g., + # relation_broken events. + # FIXME https://github.com/canonical/traefik-k8s-operator/issues/34 + return {} + + databag = relation.data[relation.app] + remote_data = {} # type: Dict[str, Union[int, str]] + for k in ("port", "host", "model", "name", "mode", "strip-prefix"): + v = databag.get(k) + if v is not None: + remote_data[k] = v + _validate_data(remote_data, INGRESS_REQUIRES_APP_SCHEMA) + remote_data["port"] = int(remote_data["port"]) + remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", False)) + return remote_data + + def get_data(self, relation: Relation) -> RequirerData: + """Fetch the remote app's databag, i.e. the requirer data.""" + return self._get_requirer_data(relation) + + def is_ready(self, relation: Relation = None): + """The Provider is ready if the requirer has sent valid data.""" + if not relation: + return any(map(self.is_ready, self.relations)) + + try: + return bool(self._get_requirer_data(relation)) + except DataValidationError as e: + log.warning("Requirer not ready; validation error encountered: %s" % str(e)) + return False + + def _provided_url(self, relation: Relation) -> ProviderIngressData: + """Fetch and validate this app databag; return the ingress url.""" + if not all((relation.app, relation.app.name, self.unit.is_leader())): + # Handle edge case where remote app name can be missing, e.g., + # relation_broken events. + # Also, only leader units can read own app databags. + # FIXME https://github.com/canonical/traefik-k8s-operator/issues/34 + return {} # noqa + + # fetch the provider's app databag + raw_data = relation.data[self.app].get("ingress") + if not raw_data: + raise RuntimeError("This application did not `publish_url` yet.") + + ingress: ProviderIngressData = yaml.safe_load(raw_data) + _validate_data({"ingress": ingress}, INGRESS_PROVIDES_APP_SCHEMA) + return ingress + + def publish_url(self, relation: Relation, url: str): + """Publish to the app databag the ingress url.""" + ingress = {"url": url} + ingress_data = {"ingress": ingress} + _validate_data(ingress_data, INGRESS_PROVIDES_APP_SCHEMA) + relation.data[self.app]["ingress"] = yaml.safe_dump(ingress) + + @property + def proxied_endpoints(self): + """Returns the ingress settings provided to applications by this IngressPerAppProvider. + + For example, when this IngressPerAppProvider has provided the + `http://foo.bar/my-model.my-app` URL to the my-app application, the returned dictionary + will be: + + ``` + { + "my-app": { + "url": "http://foo.bar/my-model.my-app" + } + } + ``` + """ + results = {} + + for ingress_relation in self.relations: + results[ingress_relation.app.name] = self._provided_url(ingress_relation) + + return results + + +class IngressPerAppReadyEvent(_IPAEvent): + """Event representing that ingress for an app is ready.""" + + __args__ = ("url",) + if typing.TYPE_CHECKING: + url = None # type: str + + +class IngressPerAppRevokedEvent(RelationEvent): + """Event representing that ingress for an app has been revoked.""" + + +class IngressPerAppRequirerEvents(ObjectEvents): + """Container for IPA Requirer events.""" + + ready = EventSource(IngressPerAppReadyEvent) + revoked = EventSource(IngressPerAppRevokedEvent) + + +class IngressPerAppRequirer(_IngressPerAppBase): + """Implementation of the requirer of the ingress relation.""" + + on = IngressPerAppRequirerEvents() + # used to prevent spur1ious urls to be sent out if the event we're currently + # handling is a relation-broken one. + _stored = StoredState() + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + *, + host: str = None, + port: int = None, + strip_prefix: bool = False, + ): + """Constructor for IngressRequirer. + + The request args can be used to specify the ingress properties when the + instance is created. If any are set, at least `port` is required, and + they will be sent to the ingress provider as soon as it is available. + All request args must be given as keyword args. + + Args: + charm: the charm that is instantiating the library. + relation_name: the name of the relation endpoint to bind to (defaults to `ingress`); + relation must be of interface type `ingress` and have "limit: 1") + host: Hostname to be used by the ingress provider to address the requiring + application; if unspecified, the default Kubernetes service name will be used. + strip_prefix: configure Traefik to strip the path prefix. + + Request Args: + port: the port of the service + """ + super().__init__(charm, relation_name) + self.charm: CharmBase = charm + self.relation_name = relation_name + self._strip_prefix = strip_prefix + + self._stored.set_default(current_url=None) + + # if instantiated with a port, and we are related, then + # we immediately publish our ingress data to speed up the process. + if port: + self._auto_data = host, port + else: + self._auto_data = None + + def _handle_relation(self, event): + # created, joined or changed: if we have auto data: publish it + self._publish_auto_data(event.relation) + + if self.is_ready(): + # Avoid spurious events, emit only when there is a NEW URL available + new_url = ( + None + if isinstance(event, RelationBrokenEvent) + else self._get_url_from_relation_data() + ) + if self._stored.current_url != new_url: + self._stored.current_url = new_url + self.on.ready.emit(event.relation, new_url) + + def _handle_relation_broken(self, event): + self._stored.current_url = None + self.on.revoked.emit(event.relation) + + def _handle_upgrade_or_leader(self, event): + """On upgrade/leadership change: ensure we publish the data we have.""" + for relation in self.relations: + self._publish_auto_data(relation) + + def is_ready(self): + """The Requirer is ready if the Provider has sent valid data.""" + try: + return bool(self._get_url_from_relation_data()) + except DataValidationError as e: + log.warning("Requirer not ready; validation error encountered: %s" % str(e)) + return False + + def _publish_auto_data(self, relation: Relation): + if self._auto_data and self.unit.is_leader(): + host, port = self._auto_data + self.provide_ingress_requirements(host=host, port=port) + + def provide_ingress_requirements(self, *, host: str = None, port: int): + """Publishes the data that Traefik needs to provide ingress. + + NB only the leader unit is supposed to do this. + + Args: + host: Hostname to be used by the ingress provider to address the + requirer unit; if unspecified, FQDN will be used instead + port: the port of the service (required) + """ + # get only the leader to publish the data since we only + # require one unit to publish it -- it will not differ between units, + # unlike in ingress-per-unit. + assert self.unit.is_leader(), "only leaders should do this." + assert self.relation, "no relation" + + if not host: + host = socket.getfqdn() + + data = { + "model": self.model.name, + "name": self.app.name, + "host": host, + "port": str(port), + } + + if self._strip_prefix: + data["strip-prefix"] = "true" + + _validate_data(data, INGRESS_REQUIRES_APP_SCHEMA) + self.relation.data[self.app].update(data) + + @property + def relation(self): + """The established Relation instance, or None.""" + return self.relations[0] if self.relations else None + + def _get_url_from_relation_data(self) -> Optional[str]: + """The full ingress URL to reach the current unit. + + Returns None if the URL isn't available yet. + """ + relation = self.relation + if not relation: + return None + + # fetch the provider's app databag + try: + raw = relation.data.get(relation.app, {}).get("ingress") + except ModelError as e: + log.debug( + f"Error {e} attempting to read remote app data; " + f"probably we are in a relation_departed hook" + ) + return None + + if not raw: + return None + + ingress: ProviderIngressData = yaml.safe_load(raw) + _validate_data({"ingress": ingress}, INGRESS_PROVIDES_APP_SCHEMA) + return ingress["url"] + + @property + def url(self) -> Optional[str]: + """The full ingress URL to reach the current unit. + + Returns None if the URL isn't available yet. + """ + data = self._stored.current_url or None # type: ignore + assert isinstance(data, (str, type(None))) # for static checker + return data diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..c19576e --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,22 @@ +name: openstack-hypervisor + +display-name: OpenStack Hypervisor + +summary: Deploy the OpenStack hypervisor + +description: | + Configure machine to run VMs as part of an OpenStack cloud. + +requires: + amqp: + interface: rabbitmq + identity-credentials: + interface: keystone-credentials + ovsdb-cms: + interface: ovsdb-cms + certificates: + interface: tls-certificates + optional: true +peers: + peers: + interface: hypervisor-peer diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2edc519 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +# Testing tools configuration +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +# Formatting tools configuration +[tool.black] +line-length = 99 +target-version = ["py38"] + +[tool.isort] +line_length = 99 +profile = "black" + +# Linting tools configuration +[tool.flake8] +max-line-length = 99 +max-doc-length = 99 +max-complexity = 10 +exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"] +select = ["E", "W", "F", "C", "N", "R", "D", "H"] +# Ignore W503, E501 because using black creates errors with this +# Ignore D107 Missing docstring in __init__ +ignore = ["W503", "E501", "D107"] +# D100, D101, D102, D103: Ignore missing docstrings in tests +per-file-ignores = ["tests/*:D100,D101,D102,D103,D104"] +docstring-convention = "google" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b9ec1f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +cryptography +ops >= 1.5.0 +pyroute2 +netifaces +jsonschema +jinja2 +#git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam +#git+https://github.com/gnuoy/charm-ops-sunbeam@support-machine-charms#egg=ops_sunbeam +git+https://github.com/gnuoy/charm-ops-sunbeam@hypervisor-wip2#egg=ops_sunbeam + +# This charm does not use lightkube* but ops_sunbeam requires it atm +lightkube +lightkube-models diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..b81caa5 --- /dev/null +++ b/src/charm.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +# 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. + + +"""OpenStack Hypervisor Operator Charm. + +This charm provide hypervisor services as part of an OpenStack deployment +""" + +import base64 +import logging +import secrets +import socket +import string +import subprocess +from typing import List + +import ops_sunbeam.charm as sunbeam_charm +import ops_sunbeam.guard as sunbeam_guard +import ops_sunbeam.ovn.relation_handlers as ovn_relation_handlers +import ops_sunbeam.relation_handlers as sunbeam_rhandlers +from netifaces import AF_INET, gateways, ifaddresses +from ops.framework import StoredState +from ops.main import main + +logger = logging.getLogger(__name__) + + +def _get_local_ip_by_default_route() -> str: + """Get IP address of host associated with default gateway.""" + interface = "lo" + ip = "127.0.0.1" + + # TOCHK: Gathering only IPv4 + if "default" in gateways(): + interface = gateways()["default"][AF_INET][1] + + ip_list = ifaddresses(interface)[AF_INET] + if len(ip_list) > 0 and "addr" in ip_list[0]: + ip = ip_list[0]["addr"] + + return ip + + +class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm): + """Charm the service.""" + + _state = StoredState() + service_name = "hypervisor" + METADATA_SECRET_KEY = "ovn-metadata-proxy-shared-secret" + DEFAULT_SECRET_LENGTH = 32 + + def get_relation_handlers( + self, handlers: List[sunbeam_rhandlers.RelationHandler] = None + ) -> List[sunbeam_rhandlers.RelationHandler]: + """Relation handlers for the service.""" + handlers = handlers or [] + if self.can_add_handler("ovsdb-cms", handlers): + self.ovsdb_cms = ovn_relation_handlers.OVSDBCMSRequiresHandler( + self, + "ovsdb-cms", + self.configure_charm, + "ovsdb-cms" in self.mandatory_relations, + ) + handlers.append(self.ovsdb_cms) + handlers = super().get_relation_handlers(handlers) + return handlers + + def generate_metadata_secret(self) -> str: + """Generate a secure secret. + + :param length: length of generated secret + :type length: int + :return: string containing the generated secret + """ + return "".join( + secrets.choice(string.ascii_letters + string.digits) + for i in range(self.DEFAULT_SECRET_LENGTH) + ) + + def metadata_secret(self) -> str: + """Retrieve or set self.METADATA_SECRET_KEY.""" + if self.leader_get(self.METADATA_SECRET_KEY): + logging.debug("Found {} in leader db".format(self.METADATA_SECRET_KEY)) + return self.leader_get(self.METADATA_SECRET_KEY) + if self.unit.is_leader(): + logging.debug("Generating new {}".format(self.METADATA_SECRET_KEY)) + secret = self.generate_metadata_secret() + self.leader_set({self.METADATA_SECRET_KEY: secret}) + return secret + else: + logging.debug( + "{} is missing, need leader to generate it".format(self.METADATA_SECRET_KEY) + ) + raise AttributeError + + def configure_unit(self, event) -> None: + """Run configuration on this unit.""" + self.check_leader_ready() + self.check_relation_handlers_ready() + config = self.model.config.get + subprocess.check_call( + [ + "snap", + "install", + "openstack-hypervisor", + "--channel", + config("snap-channel"), + ] + ) + local_ip = _get_local_ip_by_default_route() + try: + snap_data = { + "compute.cpu-mode": "host-model", + "compute.spice-proxy-address": config("ip-address") or local_ip, + "compute.virt-type": "kvm", + "credentials.ovn-metadata-proxy-shared-secret": self.metadata_secret(), + "identity.auth-url": "http://{}/openstack-keystone".format( + self.contexts().identity_credentials.auth_host + ), + "identity.password": self.contexts().identity_credentials.password, + "identity.project-domain-name": self.contexts().identity_credentials.project_domain_name, + "identity.project-name": self.contexts().identity_credentials.project_name, + "identity.region-name": self.contexts().identity_credentials.region, + "identity.user-domain-name": self.contexts().identity_credentials.user_domain_name, + "identity.username": self.contexts().identity_credentials.username, + "logging.debug": config("debug"), + "network.dns-domain": config("dns-domain"), + "network.dns-servers": config("dns-servers"), + "network.enable-gateway": config("enable-gateway"), + "network.external-bridge": config("external-bridge"), + "network.external-bridge-address": config("external-bridge-address"), + "network.ip-address": config("ip-address") or local_ip, + "network.ovn-key": base64.b64encode( + self.contexts().certificates.key.encode() + ).decode(), + "network.ovn-cert": base64.b64encode( + self.contexts().certificates.cert.encode() + ).decode(), + "network.ovn-cacert": base64.b64encode( + self.contexts().certificates.ca_cert.encode() + ).decode(), + "network.ovn-sb-connection": list( + self.contexts().ovsdb_cms.db_public_sb_connection_strs + )[0], + "network.physnet-name": config("physnet-name"), + "node.fqdn": config("fqdn") or socket.getfqdn, + "node.ip-address": config("ip-address") or local_ip, + "rabbitmq.url": self.contexts().amqp.transport_url, + } + + cmd = ["snap", "set", "openstack-hypervisor"] + [ + f"{k}={v}" for k, v in snap_data.items() + ] + except AttributeError as e: + raise sunbeam_guard.WaitingExceptionError("Data missing: {}".format(e.name)) + subprocess.check_call(cmd) + + self._state.unit_bootstrapped = True + + +if __name__ == "__main__": # pragma: no cover + main(HypervisorOperatorCharm) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..136e575 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,11 @@ +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of *requirements.txt files for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools +# + +coverage +mock +flake8 +stestr +ops diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py new file mode 100644 index 0000000..0acda5e --- /dev/null +++ b/tests/integration/test_charm.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# Copyright 2023 Liam +# See LICENSE file for licensing details. + +import asyncio +import logging +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +APP_NAME = METADATA["name"] + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest): + """Build the charm-under-test and deploy it together with related charms. + + Assert on the unit status before any relations/configurations take place. + """ + # Build and deploy charm from local source folder + charm = await ops_test.build_charm(".") + resources = {"httpbin-image": METADATA["resources"]["httpbin-image"]["upstream-source"]} + + # Deploy the charm and wait for active/idle status + await asyncio.gather( + ops_test.model.deploy(await charm, resources=resources, application_name=APP_NAME), + ops_test.model.wait_for_idle( + apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000 + ), + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..d0a9734 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Unit tests for charm.""" diff --git a/tests/unit/config.yaml b/tests/unit/config.yaml new file mode 120000 index 0000000..82800a0 --- /dev/null +++ b/tests/unit/config.yaml @@ -0,0 +1 @@ +../../config.yaml \ No newline at end of file diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py new file mode 100644 index 0000000..0e6b239 --- /dev/null +++ b/tests/unit/test_charm.py @@ -0,0 +1,56 @@ +# 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. + +"""Tests for Openstack hypervisor charm.""" + +import unittest +import mock +import ops_sunbeam.test_utils as test_utils + +import ops.testing +from ops.testing import Harness + +import charm + + +class _HypervisorOperatorCharm(charm.HypervisorOperatorCharm): + """Neutron test charm.""" + + def __init__(self, framework): + """Setup event logging.""" + self.seen_events = [] + super().__init__(framework) + +class TestCharm(test_utils.CharmTestCase): + PATCHES = ['subprocess'] + def setUp(self): + """Setup OpenStack Hypervisor tests.""" + super().setUp(charm, self.PATCHES) + with open("config.yaml", "r") as f: + config_data = f.read() + self.harness = test_utils.get_harness( + _HypervisorOperatorCharm, + container_calls=self.container_calls, + charm_config=config_data + ) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def test_all_relations(self): + """Test all the charms relations.""" + self.harness.set_leader() + self.harness.update_config({'snap-channel': 'essex/stable'}) + test_utils.add_all_relations(self.harness) + self.subprocess.check_call.assert_any_call( + ['snap', 'install', 'openstack-hypervisor', '--channel', 'essex/stable']) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..40a1a3d --- /dev/null +++ b/tox.ini @@ -0,0 +1,164 @@ +# Source charm: ./tox.ini +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of tox.ini for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools + +[tox] +skipsdist = True +envlist = pep8,py3 +sitepackages = False +skip_missing_interpreters = False +minversion = 3.18.0 + +[vars] +src_path = {toxinidir}/src/ +tst_path = {toxinidir}/tests/ +lib_path = {toxinidir}/lib/ +pyproject_toml = {toxinidir}/pyproject.toml +all_path = {[vars]src_path} {[vars]tst_path} + +[testenv] +basepython = python3 +setenv = + PYTHONPATH = {toxinidir}:{[vars]lib_path}:{[vars]src_path} +passenv = + PYTHONPATH +install_command = + pip install {opts} {packages} +commands = stestr run --slowest {posargs} +allowlist_externals = + git + charmcraft + {toxinidir}/fetch-libs.sh + {toxinidir}/rename.sh +deps = + -r{toxinidir}/test-requirements.txt + +[testenv:fmt] +description = Apply coding style standards to code +deps = + black + isort +commands = + isort {[vars]all_path} --skip-glob {[vars]lib_path} --skip {toxinidir}/.tox + black --config {[vars]pyproject_toml} {[vars]all_path} --exclude {[vars]lib_path} + +[testenv:build] +basepython = python3 +deps = +commands = + charmcraft -v pack + {toxinidir}/rename.sh + +[testenv:fetch] +basepython = python3 +deps = +commands = + {toxinidir}/fetch-libs.sh + +[testenv:py3] +basepython = python3 +deps = + {[testenv]deps} + -r{toxinidir}/requirements.txt + +[testenv:py38] +basepython = python3.8 +deps = {[testenv:py3]deps} + +[testenv:py39] +basepython = python3.9 +deps = {[testenv:py3]deps} + +[testenv:py310] +basepython = python3.10 +deps = {[testenv:py3]deps} + +[testenv:cover] +basepython = python3 +deps = {[testenv:py3]deps} +setenv = + {[testenv]setenv} + PYTHON=coverage run +commands = + coverage erase + stestr run --slowest {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + coverage report + +[testenv:pep8] +description = Alias for lint +deps = {[testenv:lint]deps} +commands = {[testenv:lint]commands} + +[testenv:lint] +description = Check code against coding style standards +deps = + black + flake8<6 # Pin version until https://github.com/savoirfairelinux/flake8-copyright/issues/19 is merged + flake8-docstrings + flake8-copyright + flake8-builtins + pyproject-flake8 + pep8-naming + isort + codespell +commands = + codespell {[vars]all_path} + # pflake8 wrapper supports config from pyproject.toml + pflake8 --exclude {[vars]lib_path} --config {toxinidir}/pyproject.toml {[vars]all_path} + isort --check-only --diff {[vars]all_path} --skip-glob {[vars]lib_path} + black --config {[vars]pyproject_toml} --check --diff {[vars]all_path} --exclude {[vars]lib_path} + +[testenv:func-noop] +basepython = python3 +deps = + git+https://github.com/openstack-charmers/zaza.git@libjuju-3.1#egg=zaza + git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack + git+https://opendev.org/openstack/tempest.git#egg=tempest +commands = + functest-run-suite --help + +[testenv:func] +basepython = python3 +deps = {[testenv:func-noop]deps} +commands = + functest-run-suite --keep-model + +[testenv:func-smoke] +basepython = python3 +deps = {[testenv:func-noop]deps} +setenv = + TEST_MODEL_SETTINGS = automatically-retry-hooks=true + TEST_MAX_RESOLVE_COUNT = 5 +commands = + functest-run-suite --keep-model --smoke + +[testenv:func-dev] +basepython = python3 +deps = {[testenv:func-noop]deps} +commands = + functest-run-suite --keep-model --dev + +[testenv:func-target] +basepython = python3 +deps = {[testenv:func-noop]deps} +commands = + functest-run-suite --keep-model --bundle {posargs} + +[coverage:run] +branch = True +concurrency = multiprocessing +parallel = True +source = + . +omit = + .tox/* + tests/* + src/templates/* + +[flake8] +ignore=E226,W504