diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24ff2e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea +.vscode/ +*.swp +.stestr/ diff --git a/.gitreview b/.gitreview new file mode 100644 index 0000000..724badb --- /dev/null +++ b/.gitreview @@ -0,0 +1,5 @@ +[gerrit] +host=review.opendev.org +port=29418 +project=openstack/charm-designate-k8s.git +defaultbranch=main 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..20e88bc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +To make contributions to this charm, you'll need a working [development setup](https://juju.is/docs/sdk/dev-setup). + +You can create an environment for development with `tox`: + +```shell +tox devenv -e integration +source venv/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 run -e format # update your code according to linting rules +tox run -e lint # code style +tox run -e static # static type checking +tox run -e unit # unit tests +tox run -e integration # integration tests +tox # runs 'format', 'lint', 'static', and 'unit' environments +``` + +## Build the charm + +Build the charm in this git repository using: + +```shell +charmcraft pack +``` + + + +[contributors-guide]: https://opendev.org/openstack/charm-designate-k8s/src/branch/main/CONTRIBUTING.md +[juju-docs-actions]: https://jaas.ai/docs/actions +[juju-docs-config-apps]: https://juju.is/docs/configuring-applications +[lp-bugs-charm-designate-k8s]: https://bugs.launchpad.net/charm-designate-k8s/+filebug diff --git a/actions.yaml b/actions.yaml new file mode 100644 index 0000000..88e6195 --- /dev/null +++ b/actions.yaml @@ -0,0 +1,2 @@ +# NOTE: no actions yet! +{ } diff --git a/charmcraft.yaml b/charmcraft.yaml new file mode 100644 index 0000000..ac49568 --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,30 @@ +type: "charm" +bases: + - build-on: + - name: "ubuntu" + channel: "22.04" + run-on: + - name: "ubuntu" + channel: "22.04" +parts: + update-certificates: + plugin: nil + override-build: | + apt update + apt install -y ca-certificates + update-ca-certificates + + charm: + after: [update-certificates] + build-packages: + - git + - libffi-dev + - libssl-dev + - rustc + - cargo + - pkg-config + charm-binary-python-packages: + - cryptography + - jsonschema + - jinja2 + - git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..aeb8a5e --- /dev/null +++ b/config.yaml @@ -0,0 +1,34 @@ +options: + debug: + default: False + description: Enable debug logging. + type: boolean + os-admin-hostname: + default: designate.juju + description: | + The hostname or address of the admin endpoints that should be advertised + in the designate image provider. + type: string + os-internal-hostname: + default: designate.juju + description: | + The hostname or address of the internal endpoints that should be advertised + in the designate image provider. + type: string + os-public-hostname: + default: designate.juju + description: | + The hostname or address of the internal endpoints that should be advertised + in the designate image provider. + type: string + region: + default: RegionOne + description: Space delimited list of OpenStack regions + type: string + nameservers: + type: string + default: + description: | + Space delimited list of nameservers. These are the nameservers that have + been provided to the domain registrar in order to delegate the domain to + Designate. e.g. "ns1.example.com. ns2.example.com." diff --git a/fetch-libs.sh b/fetch-libs.sh new file mode 100755 index 0000000..4e7ebe8 --- /dev/null +++ b/fetch-libs.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +echo "INFO: Fetching libs from charmhub." +charmcraft fetch-lib charms.data_platform_libs.v0.database_requires +charmcraft fetch-lib charms.keystone_k8s.v1.identity_service +charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq +charmcraft fetch-lib charms.traefik_k8s.v1.ingress +charmcraft fetch-lib charms.bind9_k8s.v0.bind_rndc + diff --git a/lib/charms/bind9_k8s/v0/bind_rndc.py b/lib/charms/bind9_k8s/v0/bind_rndc.py new file mode 100644 index 0000000..17e5733 --- /dev/null +++ b/lib/charms/bind9_k8s/v0/bind_rndc.py @@ -0,0 +1,348 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +"""BindRndc Provides and Requires module. + +This library contains the Requires and Provides classes for handling +the bind_rndc interface. +Import `BindRndcRequires` in your charm, with the charm object and the +relation name: + - self + - "dns-backend" +Two events are also available to respond to: + - bind_rndc_ready + - goneaway +A basic example showing the usage of this relation follows: +``` +from charms.bind9_k8s.v0.bind_rndc import ( + BindRndcRequires +) +class BindRndcClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # BindRndc Requires + self.bind_rndc = BindRndcRequires( + self, "dns-backend" + ) + self.framework.observe( + self.bind_rndc.on.bind_rndc_ready, + self._on_bind_rndc_ready + ) + self.framework.observe( + self.bind_rndc.on.goneaway, + self._on_bind_rndc_goneaway + ) + def _on_bind_rndc_ready(self, event): + '''React to the Bind Rndc Ready event. + This event happens when BindRndc relation is added to the + model, relation is ready and/or relation data is changed. + ''' + # Do something with the configuration provided by relation. + pass + def _on_bind_rndc_goneaway(self, event): + '''React to the BindRndc goneaway event. + This event happens when BindRndc relation is removed. + ''' + # BindRndc Relation has goneaway. + pass +``` +""" + +import json +import logging +import secrets +from typing import ( + Any, +) + +import ops + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "0fb2f64f2a1344feb80044cee22ef3a8" + +# 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 + + +class BindRndcReadyEvent(ops.EventBase): + """Bind rndc ready event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + algorithm: str, + secret: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.algorithm = algorithm + self.secret = secret + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "algorithm": self.algorithm, + "secret": self.secret, + } + + def restore(self, snapshot: dict[str, Any]): + """Restore the value state from a given snapshot.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.algorithm = snapshot["algorithm"] + self.secret = snapshot["secret"] + + +class BindRndcGoneAwayEvent(ops.EventBase): + """Bind rndc gone away event.""" + + pass + + +class BindRndcRequirerEvents(ops.ObjectEvents): + """List of events that the BindRndc requires charm can leverage.""" + + bind_rndc_ready = ops.EventSource(BindRndcReadyEvent) + goneaway = ops.EventSource(BindRndcGoneAwayEvent) + + +class BindRndcRequires(ops.Object): + """Class to be instantiated by the requiring side of the relation.""" + + on = BindRndcRequirerEvents() + _stored = ops.StoredState() + + def __init__(self, charm: ops.CharmBase, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self._stored.set_default(nonce="") + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_relation_broken, + ) + + def _on_relation_joined(self, event: ops.RelationJoinedEvent): + """Handle relation joined event.""" + self._request_rndc_key(event.relation) + + def _on_relation_changed(self, event: ops.RelationJoinedEvent): + """Handle relation changed event.""" + host = self.host(event.relation) + rndc_key = self.get_rndc_key(event.relation) + if rndc_key is None: + self._request_rndc_key(event.relation) + return + + if host is not None: + algorithm = rndc_key["algorithm"] + secret = rndc_key["secret"] + self.on.bind_rndc_ready.emit( + event.relation.id, + event.relation.name, + algorithm, + secret, + ) + + def _on_relation_broken(self, event: ops.RelationBrokenEvent): + """Handle relation broken event.""" + self.on.goneaway.emit() + + def host(self, relation: ops.Relation) -> str | None: + """Return host from relation.""" + if relation.app is None: + return None + return relation.data[relation.app].get("host") + + def nonce(self) -> str: + """Return nonce from stored state.""" + return self._stored.nonce + + def get_rndc_key(self, relation: ops.Relation) -> dict | None: + """Get rndc keys.""" + if relation.app is None: + return None + if self._stored.nonce == "": + logger.debug("No nonce set for unit yet") + return None + + return json.loads( + relation.data[relation.app].get("rndc_keys", "{}") + ).get(self._stored.nonce) + + def _request_rndc_key(self, relation: ops.Relation): + """Request rndc key over the relation.""" + if self._stored.nonce == "": + self._stored.nonce = secrets.token_hex(16) + relation.data[self.charm.unit]["nonce"] = self._stored.nonce + + def reconcile_rndc_key(self, relation: ops.Relation): + """Reconcile rndc key over the relation.""" + if self._stored.nonce != relation.data[self.charm.unit].get("nonce"): + self._stored.nonce = secrets.token_hex(16) + relation.data[self.charm.unit]["nonce"] = self._stored.nonce + + +class NewBindClientAttachedEvent(ops.EventBase): + """New bind client attached event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + } + + def restore(self, snapshot: dict[str, Any]): + """Restore the value state from a given snapshot.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + + +class BindClientUpdatedEvent(ops.EventBase): + """Bind client updated event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + } + + def restore(self, snapshot: dict[str, Any]): + """Restore the value state from a given snapshot.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + + +class BindRndcProviderEvents(ops.ObjectEvents): + """List of events that the BindRndc provider charm can leverage.""" + + new_bind_client_attached = ops.EventSource(NewBindClientAttachedEvent) + bind_client_updated = ops.EventSource(BindClientUpdatedEvent) + + +class BindRndcProvides(ops.Object): + """Class to be instantiated by the providing side of the relation.""" + + on = BindRndcProviderEvents() + + def __init__(self, charm: ops.CharmBase, 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_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_relation_changed, + ) + + def _on_relation_joined(self, event: ops.RelationJoinedEvent): + self.on.new_bind_client_attached.emit( + event.relation.id, event.relation.name + ) + if not self.charm.unit.is_leader(): + return + binding = self.model.get_binding(event.relation) + if binding is None: + raise Exception("No binding found") + address = binding.network.ingress_address + event.relation.data[self.charm.app]["host"] = str(address) + + def _on_relation_changed(self, event: ops.RelationChangedEvent): + self.on.bind_client_updated.emit( + event.relation.id, event.relation.name + ) + + def get_rndc_keys(self, relation: ops.Relation) -> dict: + """Get rndc keys.""" + return json.loads(relation.data[self.charm.app].get("rndc_keys", "{}")) + + def set_rndc_client_key( + self, + relation: ops.Relation, + client: str, + algorithm: str, + secret: ops.Secret, + ): + """Add rndc key to the relation. + + `rndc_keys` is a dict of dicts, keyed by client name. Each client + has an algorithm and secret property. The secret is a Juju secret id, + containing the actual secret needed to communicate over rndc. + """ + if not self.charm.unit.is_leader(): + logger.debug("Not leader, skipping set_rndc_client_key") + return + + keys = self.get_rndc_keys(relation) + keys[client] = { + "algorithm": algorithm, + "secret": secret.id, + } + + relation.data[self.charm.app]["rndc_keys"] = json.dumps( + keys, sort_keys=True + ) + + def remove_rndc_client_key( + self, + relation: ops.Relation, + client: str | list[str], + ): + """Remove rndc key from the relation.""" + if not self.charm.unit.is_leader(): + logger.debug("Not leader, skipping remove_rndc_client_key") + return + if isinstance(client, str): + client = [client] + keys = self.get_rndc_keys(relation) + for c in client: + keys.pop(c) + relation.data[self.charm.app]["rndc_keys"] = json.dumps( + keys, sort_keys=True + ) 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..11ffd6c --- /dev/null +++ b/lib/charms/data_platform_libs/v0/database_requires.py @@ -0,0 +1,537 @@ +# Copyright 2023 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +r"""[DEPRECATED] 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 ( + DatabaseCreatedEvent, + 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 ( + DatabaseCreatedEvent, + 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 = 6 + +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.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("endpoints") + + @property + def password(self) -> Optional[str]: + """Returns the password for the created user.""" + if not self.relation.app: + return None + + 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.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("read-only-endpoints") + + @property + def replset(self) -> Optional[str]: + """Returns the replicaset name. + + MongoDB only. + """ + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("replset") + + @property + def tls(self) -> Optional[str]: + """Returns whether TLS is configured.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("tls") + + @property + def tls_ca(self) -> Optional[str]: + """Returns TLS CA.""" + if not self.relation.app: + return None + + 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. + """ + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("uris") + + @property + def username(self) -> Optional[str]: + """Returns the created username.""" + if not self.relation.app: + return None + + 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. + """ + if not self.relation.app: + return None + + 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() # pyright: ignore [reportGeneralTypeIssues] + + def __init__( + self, + charm, + relation_name: str, + database_name: str, + extra_user_roles: Optional[str] = None, + relations_aliases: Optional[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"} + if event.app + else {} + ) + + # These are the keys that were added to the databag and triggered this event. + added = new_data.keys() - old_data.keys() + # These are the keys that were removed from the databag and triggered this event. + deleted = old_data.keys() - new_data.keys() + # These are the keys that already existed in the databag, + # but had their values changed. + changed = { + key for key in old_data.keys() & new_data.keys() if old_data[key] != new_data[key] + } + + # TODO: evaluate the possibility of losing the diff if some error + # happens in the charm before the diff is completely checked (DPE-412). + # Convert the new_data to a serializable format and save it for a next diff check. + event.relation.data[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"} + if relation.app + else {} + ) + 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()) + getattr(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()) + getattr(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()) + getattr(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/v1/identity_service.py b/lib/charms/keystone_k8s/v1/identity_service.py new file mode 100644 index 0000000..62dd9a3 --- /dev/null +++ b/lib/charms/keystone_k8s/v1/identity_service.py @@ -0,0 +1,525 @@ +"""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 = 1 + + +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') + + @property + def admin_role(self) -> str: + """Return the admin_role.""" + return self.get_remote_app_data('admin-role') + + 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, + admin_role: 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 + app_data["admin-role"] = admin_role 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/traefik_k8s/v1/ingress.py b/lib/charms/traefik_k8s/v1/ingress.py new file mode 100644 index 0000000..e393fb5 --- /dev/null +++ b/lib/charms/traefik_k8s/v1/ingress.py @@ -0,0 +1,579 @@ +# 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 = 15 + +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"}, + "redirect-https": {"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 compatibility + +# 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, + "redirect-https": bool, + }, + total=False, +) +# Provider ingress data model. +ProviderIngressData = TypedDict("ProviderIngressData", {"url": str}) +# Provider application databag model. +ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData}) # type: ignore + + +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 + "_V1") + + 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) # type: ignore + observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore + + @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__: Tuple[str, ...] = () + __optional_kwargs__: 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): + 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) -> 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", "redirect_https") + + if typing.TYPE_CHECKING: + name: Optional[str] = None + model: Optional[str] = None + port: Optional[str] = None + host: Optional[str] = None + strip_prefix: bool = False + redirect_https: bool = False + + +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() # type: ignore + + 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( # type: ignore + event.relation, + data["name"], + data["model"], + data["port"], + data["host"], + data.get("strip-prefix", False), + data.get("redirect-https", False), + ) + + def _handle_relation_broken(self, event): + self.on.data_removed.emit(event.relation) # type: ignore + + 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: # type: ignore + """Fetch and validate the requirer's app databag. + + For convenience, we convert 'port' to integer. + """ + if not relation.app or not relation.app.name: # type: ignore + # 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: Dict[str, Union[int, str]] = {} + for k in ("port", "host", "model", "name", "mode", "strip-prefix", "redirect-https"): + 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") == "true") + remote_data["redirect-https"] = bool(remote_data.get("redirect-https", "false") == "true") + return typing.cast(RequirerData, remote_data) + + def get_data(self, relation: Relation) -> RequirerData: # type: ignore + """Fetch the remote app's databag, i.e. the requirer data.""" + return self._get_requirer_data(relation) + + def is_ready(self, relation: Optional[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: # type: ignore + """Fetch and validate this app databag; return the ingress url.""" + if not relation.app or not relation.app.name or not self.unit.is_leader(): # type: ignore + # 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 typing.cast(ProviderIngressData, {}) # 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: + assert ( + ingress_relation.app + ), "no app in relation (shouldn't happen)" # for type checker + 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: Optional[str] = None + + +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() # type: ignore + + # used to prevent spurious 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: Optional[str] = None, + port: Optional[int] = None, + strip_prefix: bool = False, + redirect_https: 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. + redirect_https: redirect incoming requests to the HTTPS. + + 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._redirect_https = redirect_https + + self._stored.set_default(current_url=None) # type: ignore + + # 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: # type: ignore + self._stored.current_url = new_url # type: ignore + self.on.ready.emit(event.relation, new_url) # type: ignore + + def _handle_relation_broken(self, event): + self._stored.current_url = None # type: ignore + self.on.revoked.emit(event.relation) # type: ignore + + 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: Optional[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" + + if self._redirect_https: + data["redirect-https"] = "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 or not relation.app: + 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 self._get_url_from_relation_data() # 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..1571b8f --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,51 @@ +name: designate-k8s +summary: OpenStack designate service +maintainer: OpenStack Charmers +description: | + Designate is a multi-tenant DNSaaS service for OpenStack. It provides a REST API with integrated Keystone authentication. + It can be configured to auto-generate records based on Nova and Neutron actions. + Designate supports a variety of DNS servers including Bind9 and PowerDNS 4. +version: 3 +bases: + - name: ubuntu + channel: 22.04/stable +assumes: + - k8s-api + - juju >= 3.1 +tags: +- openstack +source: https://opendev.org/openstack/charm-designate-k8s +issues: https://bugs.launchpad.net/charm-designate-k8s + +containers: + designate: + resource: designate-image + +resources: + designate-image: + type: oci-image + description: OCI image for OpenStack designate + upstream: ghcr.io/canonical/designate-consolidated:2023.1 + +requires: + database: + interface: mysql_client + limit: 1 + identity-service: + interface: keystone + ingress-internal: + interface: ingress + optional: true + limit: 1 + ingress-public: + interface: ingress + limit: 1 + amqp: + interface: rabbitmq + dns-backend: + interface: bind-rndc + limit: 1 + +peers: + peers: + interface: designate-peer diff --git a/osci.yaml b/osci.yaml new file mode 100644 index 0000000..af2c681 --- /dev/null +++ b/osci.yaml @@ -0,0 +1,10 @@ +- project: + templates: + - charm-publish-jobs + vars: + needs_charm_build: true + charm_build_name: designate-k8s + build_type: charmcraft + publish_charm: true + charmcraft_channel: 2.0/stable + publish_channel: 2023.1/edge diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3082140 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +# 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 = 79 + +[tool.isort] +profile = "black" +multi_line_output = 3 +force_grid_wrap = true + +# Linting tools configuration +[tool.flake8] +max-line-length = 79 +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", "E402"] +per-file-ignores = [] +docstring-convention = "google" +# Check for properly formatted copyright header in each file +copyright-check = "True" +copyright-author = "Canonical Ltd." +copyright-regexp = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+%(author)s" diff --git a/rename.sh b/rename.sh new file mode 100755 index 0000000..d0c35c9 --- /dev/null +++ b/rename.sh @@ -0,0 +1,13 @@ +#!/bin/bash +charm=$(grep "charm_build_name" osci.yaml | awk '{print $2}') +echo "renaming ${charm}_*.charm to ${charm}.charm" +echo -n "pwd: " +pwd +ls -al +echo "Removing bad downloaded charm maybe?" +if [[ -e "${charm}.charm" ]]; +then + rm "${charm}.charm" +fi +echo "Renaming charm here." +mv ${charm}_*.charm ${charm}.charm diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ff55d0c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +ops +jsonschema +jinja2 +git+https://github.com/openstack/charm-ops-sunbeam#egg=ops_sunbeam +lightkube diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..3b49307 --- /dev/null +++ b/src/charm.py @@ -0,0 +1,423 @@ +#!/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. +# +# +# Learn more at: https://juju.is/docs/sdk + +"""Designate Operator Charm. + +This charm provide Designate services as part of an OpenStack deployment +""" + +import logging +from typing import ( + Callable, + List, + Mapping, +) + +import charms.bind9_k8s.v0.bind_rndc as bind_rndc +import ops +import ops.charm +import ops_sunbeam.charm as sunbeam_charm +import ops_sunbeam.container_handlers as sunbeam_chandlers +import ops_sunbeam.core as sunbeam_core +import ops_sunbeam.guard as sunbeam_guard +import ops_sunbeam.relation_handlers as sunbeam_rhandlers +import tenacity +from ops.main import ( + main, +) + +logger = logging.getLogger(__name__) + +DESIGNATE_CONTAINER = "designate" +BIND_RNDC_RELATION = "dns-backend" +RNDC_SECRET_PREFIX = "rndc_" + + +class DesignatePebbleHandler(sunbeam_chandlers.WSGIPebbleHandler): + """Pebble handler for designate services.""" + + _common_service_config = { + "override": "replace", + "user": "designate", + "group": "designate", + } + + def __init__( + self, + charm: ops.CharmBase, + container_name: str, + template_dir: str, + callback_f: Callable, + ) -> None: + self.wsgi_service_name = "wsgi-designate-api" + super().__init__( + charm, + container_name, + "designate", + [], + template_dir, + callback_f, + self.wsgi_service_name, + ) + + @property + def wsgi_conf(self) -> str: + """Location of WSGI config file.""" + return f"/etc/apache2/sites-available/{self.wsgi_service_name}.conf" + + def get_layer(self) -> dict: + """Designate service layer.""" + layer = super().get_layer() + + layer["services"].update( + { + "designate-central": { + "summary": "designate central", + "command": "designate-central", + **self._common_service_config, + }, + "designate-worker": { + "summary": "designate worker", + "command": "designate-worker", + **self._common_service_config, + }, + "designate-producer": { + "summary": "designate producer", + "command": "designate-producer", + **self._common_service_config, + }, + "designate-mdns": { + "summary": "designate mdns", + "command": "designate-mdns", + **self._common_service_config, + }, + } + ) + return layer + + def default_container_configs( + self, + ) -> List[sunbeam_core.ContainerConfigFile]: + """Container configurations for handler.""" + _cconfig = super().default_container_configs() + _cconfig.extend( + [ + sunbeam_core.ContainerConfigFile( + "/etc/designate/designate.conf", + "designate", + "designate", + ), + sunbeam_core.ContainerConfigFile( + "/etc/designate/pools.yaml", + "designate", + "designate", + ), + sunbeam_core.ContainerConfigFile( + "/etc/designate/rndc.key", + "designate", + "designate", + ), + ] + ) + return _cconfig + + def init_service(self, context: sunbeam_core.OPSCharmContexts) -> None: + """Enable and start WSGI service.""" + container = self.charm.unit.get_container(self.container_name) + try: + process = container.exec( + ["a2dissite", "000-default"], timeout=5 * 60 + ) + out, warnings = process.wait_output() + if warnings: + for line in warnings.splitlines(): + logger.warning("a2dissite warn: %s", line.strip()) + logger.debug(f"Output from a2dissite: \n{out}") + except ops.pebble.ExecError: + logger.exception("Failed to disable '000-default' site in apache") + super().init_service(context) + + +class BindRndcRequiresRelationHandler(sunbeam_rhandlers.RelationHandler): + """Relation handler class.""" + + charm: "DesignateOperatorCharm" + interface: bind_rndc.BindRndcRequires + + def __init__( + self, + charm: ops.CharmBase, + relation_name: str, + callback_f: Callable, + mandatory: bool = True, + ): + super().__init__(charm, relation_name, callback_f, mandatory) + + def setup_event_handler(self) -> ops.Object: + """Setup event handler for the relation.""" + interface = bind_rndc.BindRndcRequires(self.charm, BIND_RNDC_RELATION) + self.framework.observe( + interface.on.bind_rndc_ready, + self._on_bind_rndc_ready, + ) + self.framework.observe( + interface.on.goneaway, + self._on_bind_rndc_goneaway, + ) + return interface + + def _on_bind_rndc_ready(self, event: bind_rndc.BindRndcReadyEvent): + """Handle bind rndc ready event.""" + self.callback_f(event) + + def _on_bind_rndc_goneaway(self, event: bind_rndc.BindRndcGoneAwayEvent): + """Handle bind rndc goneaway event.""" + self.callback_f(event) + + @property + def _relation(self) -> ops.Relation: + """Get relation.""" + relation = self.framework.model.get_relation(self.relation_name) + if relation is None: + raise Exception("Relation not found") + return relation + + @property + def ready(self) -> bool: + """Ready when a key is available for current unit.""" + try: + relation = self._relation + self.interface.reconcile_rndc_key(relation) + return self.interface.get_rndc_key(relation) is not None + except Exception: + return False + + @property + def rndc_key(self) -> dict: + """Return current's unit rndc key which secret rendered.""" + rndc_key_secret = self.interface.get_rndc_key(self._relation) + if rndc_key_secret is None: + raise sunbeam_guard.BlockedExceptionError("Rndc missing") + + unit_name = self.charm.unit.name.replace("/", "-") + rndc_key = rndc_key_secret.copy() + secret = self.charm.model.get_secret( + id=rndc_key["secret"], label=RNDC_SECRET_PREFIX + unit_name + ) + secret_value = secret.get_content()["secret"] + rndc_key["secret"] = secret_value + rndc_key["name"] = self.interface.nonce() + + return rndc_key + + @property + def bind_host(self) -> str: + """Get bind host ip.""" + host = self.interface.host(self._relation) + if host is None: + raise sunbeam_guard.BlockedExceptionError("Host missing") + return host + + @property + def mdns_ip(self) -> str: + """Get mdns ip. + + Return the ingress address, on a K8S cloud this will be IP address + from the service definition, such as a ClusterIP or a LoadBalancer. + + Related bind will use this address to transfer zones for example. + """ + binding = self.model.get_binding(self._relation) + if binding is None: + raise Exception("Binding not found") + return str(binding.network.ingress_address) + + def context(self) -> dict: + """Render context needed for jinja templating.""" + return { + "rndc_key": self.rndc_key, + "host": self.bind_host, + "mdns_ip": self.mdns_ip, + "rndc_file_key": "/etc/designate/rndc.key", + "ns_records": self.charm.ns_records, + } + + +class DesignateOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): + """Charm the service.""" + + _state = ops.StoredState() + service_name = "designate" + wsgi_admin_script = "/usr/bin/designate-api-wsgi" + wsgi_public_script = "/usr/bin/designate-api-wsgi" + + db_sync_cmds = [ + ["sudo", "-u", "designate", "designate-manage", "database", "sync"], + ] + pool_sync_cmds = [ + ["sudo", "-u", "designate", "designate-manage", "pool", "update"], + ] + + mandatory_relations = { + "database", + "identity-service", + "ingress-public", + "amqp", + BIND_RNDC_RELATION, + } + + def configure_unit(self, event: ops.framework.EventBase) -> None: + """Run configuration on this unit.""" + self.check_leader_ready() + self.check_relation_handlers_ready() + self.open_ports() + self.init_container_services() + self.check_pebble_handlers_ready() + self.run_db_sync() + self.run_pool_update() + self._state.unit_bootstrapped = True + + def open_ports(self): + """Register ports in underlying cloud.""" + super().open_ports() + self.unit.open_port("tcp", 5354) # mdns port + + def get_pebble_handlers( + self, + ) -> List[sunbeam_chandlers.PebbleHandler]: + """Pebble handlers for operator.""" + return [ + DesignatePebbleHandler( + self, + DESIGNATE_CONTAINER, + self.template_dir, + self.configure_charm, + ), + ] + + def get_relation_handlers( + self, handlers=None + ) -> List[sunbeam_rhandlers.RelationHandler]: + """Relation handlers for the service.""" + handlers = handlers or [] + if self.can_add_handler(BIND_RNDC_RELATION, handlers): + self.bind_rndc = BindRndcRequiresRelationHandler( + self, + BIND_RNDC_RELATION, + self.configure_charm, + mandatory=BIND_RNDC_RELATION in self.mandatory_relations, + ) + handlers.append(self.bind_rndc) + + return super().get_relation_handlers(handlers) + + @property + def databases(self) -> Mapping[str, str]: + """Databases needed to support this charm. + + Need to override the default + because we're registering multiple databases. + """ + return { + "database": "designate", + "shared-database": "designate_shared", + } + + @property + def service_conf(self) -> str: + """Service default configuration file.""" + return "/etc/designate/designate.conf" + + @property + def service_user(self) -> str: + """Service user file and directory ownership.""" + return "designate" + + @property + def service_group(self) -> str: + """Service group file and directory ownership.""" + return "designate" + + @property + def service_endpoints(self): + """Return service endpoints for the service.""" + return [ + { + "service_name": "designate", + "type": "dns", + "description": "OpenStack Designate API", + "internal_url": self.internal_url, + "public_url": self.public_url, + "admin_url": self.admin_url, + } + ] + + @property + def default_public_ingress_port(self): + """Ingress Port for API service.""" + return 9001 + + @property + def ns_records(self) -> list[str]: + """Get nameserver records.""" + nameservers = self.config.get("nameservers") + if nameservers is None: + return [] + return nameservers.split(" ") + + @tenacity.retry( + stop=tenacity.stop_after_attempt(3), + retry=( + tenacity.retry_if_exception_type(ops.pebble.ChangeError) + | tenacity.retry_if_exception_type(ops.pebble.ExecError) + ), + after=tenacity.after_log(logger, logging.WARNING), + wait=tenacity.wait_exponential(multiplier=1, min=10, max=300), + ) + def _retry_pool_update(self, cmd): + container = self.unit.get_container(DESIGNATE_CONTAINER) + logger.debug("Running pool update: \n%s", cmd) + process = container.exec(cmd, timeout=5 * 60) + out, warnings = process.wait_output() + if warnings: + logger.debug("Pool update stdout: \n%s", out) + for line in warnings.splitlines(): + logger.warning("Pool update stderr: \n%s", line.strip()) + + def run_pool_update(self) -> None: + """Update designate pools. + + :raises: pebble.ExecError + """ + if not self.unit.is_leader(): + logger.info("Not lead unit, skipping pool update") + return + logger.info("Updating pools...") + + for cmd in self.pool_sync_cmds: + try: + self._retry_pool_update(cmd) + except tenacity.RetryError: + raise sunbeam_guard.BlockedExceptionError( + "Updating pools failed" + ) + + +if __name__ == "__main__": + main(DesignateOperatorCharm) diff --git a/src/templates/designate.conf.j2 b/src/templates/designate.conf.j2 new file mode 100644 index 0000000..2960696 --- /dev/null +++ b/src/templates/designate.conf.j2 @@ -0,0 +1,24 @@ +[DEFAULT] +debug = {{ options.debug }} +lock_path = /var/lock/designate +state_path = /var/lib/designate + +transport_url = {{ amqp.transport_url }} + +{% include "parts/section-identity" %} + +{% include "parts/section-service-user" %} + +[service:api] +auth_strategy = keystone +enable_api_v2 = True +enable_api_admin = True +enable_host_header = True +enabled_extensions_admin = reports, quotas, counts, tenants, zones + +[service:worker] +enabled = True + +[storage:sqlalchemy] +{% include "parts/database-connection" %} +db_auto_create = false diff --git a/src/templates/parts/database-connection b/src/templates/parts/database-connection new file mode 100644 index 0000000..c62efba --- /dev/null +++ b/src/templates/parts/database-connection @@ -0,0 +1,4 @@ +{% if database.connection -%} +connection = {{ database.connection }} +{# ?charset=utf8 #} +{% endif -%} diff --git a/src/templates/parts/identity-data b/src/templates/parts/identity-data new file mode 100644 index 0000000..706d9d1 --- /dev/null +++ b/src/templates/parts/identity-data @@ -0,0 +1,23 @@ +{% if identity_service.admin_auth_url -%} +auth_url = {{ identity_service.admin_auth_url }} +interface = admin +{% elif identity_service.internal_auth_url -%} +auth_url = {{ identity_service.internal_auth_url }} +interface = internal +{% elif identity_service.internal_host -%} +auth_url = {{ identity_service.internal_protocol }}://{{ identity_service.internal_host }}:{{ identity_service.internal_port }} +interface = internal +{% endif -%} +{% if identity_service.public_auth_url -%} +www_authenticate_uri = {{ identity_service.public_auth_url }} +{% elif identity_service.internal_host -%} +www_authenticate_uri = {{ identity_service.internal_protocol }}://{{ identity_service.internal_host }}:{{ identity_service.internal_port }} +{% endif -%} +auth_type = password +project_domain_name = {{ identity_service.service_domain_name }} +user_domain_name = {{ identity_service.service_domain_name }} +project_name = {{ identity_service.service_project_name }} +username = {{ identity_service.service_user_name }} +password = {{ identity_service.service_password }} +service_token_roles = {{ identity_service.admin_role }} +service_token_roles_required = True diff --git a/src/templates/parts/section-database b/src/templates/parts/section-database new file mode 100644 index 0000000..b060b13 --- /dev/null +++ b/src/templates/parts/section-database @@ -0,0 +1,4 @@ +[database] +{% include "parts/database-connection" %} +connection_recycle_time = 200 +db_auto_create = false diff --git a/src/templates/parts/section-federation b/src/templates/parts/section-federation new file mode 100644 index 0000000..65ee99e --- /dev/null +++ b/src/templates/parts/section-federation @@ -0,0 +1,10 @@ +{% if trusted_dashboards %} +[federation] +{% for dashboard_url in trusted_dashboards -%} +trusted_dashboard = {{ dashboard_url }} +{% endfor -%} +{% endif %} +{% for sp in fid_sps -%} +[{{ sp['protocol-name'] }}] +remote_id_attribute = {{ sp['remote-id-attribute'] }} +{% endfor -%} diff --git a/src/templates/parts/section-identity b/src/templates/parts/section-identity new file mode 100644 index 0000000..7568a9a --- /dev/null +++ b/src/templates/parts/section-identity @@ -0,0 +1,2 @@ +[keystone_authtoken] +{% include "parts/identity-data" %} diff --git a/src/templates/parts/section-middleware b/src/templates/parts/section-middleware new file mode 100644 index 0000000..e65f1d9 --- /dev/null +++ b/src/templates/parts/section-middleware @@ -0,0 +1,6 @@ +{% for section in sections -%} +[{{section}}] +{% for key, value in sections[section].items() -%} +{{ key }} = {{ value }} +{% endfor %} +{%- endfor %} diff --git a/src/templates/parts/section-service-user b/src/templates/parts/section-service-user new file mode 100644 index 0000000..6510369 --- /dev/null +++ b/src/templates/parts/section-service-user @@ -0,0 +1,17 @@ +{% if identity_service.service_domain_id -%} +[service_user] +{% if identity_service.admin_auth_url -%} +auth_url = {{ identity_service.admin_auth_url }} +{% elif identity_service.internal_auth_url -%} +auth_url = {{ identity_service.internal_auth_url }} +{% elif identity_service.internal_host -%} +auth_url = {{ identity_service.internal_protocol }}://{{ identity_service.internal_host }}:{{ identity_service.internal_port }} +{% endif -%} +send_service_user_token = true +auth_type = password +project_domain_id = {{ identity_service.service_domain_id }} +user_domain_id = {{ identity_service.service_domain_id }} +project_name = {{ identity_service.service_project_name }} +username = {{ identity_service.service_user_name }} +password = {{ identity_service.service_password }} +{% endif -%} diff --git a/src/templates/parts/section-signing b/src/templates/parts/section-signing new file mode 100644 index 0000000..cb7d69a --- /dev/null +++ b/src/templates/parts/section-signing @@ -0,0 +1,15 @@ +{% if enable_signing -%} +[signing] +{% if certfile -%} +certfile = {{ certfile }} +{% endif -%} +{% if keyfile -%} +keyfile = {{ keyfile }} +{% endif -%} +{% if ca_certs -%} +ca_certs = {{ ca_certs }} +{% endif -%} +{% if ca_key -%} +ca_key = {{ ca_key }} +{% endif -%} +{% endif -%} \ No newline at end of file diff --git a/src/templates/pools.yaml.j2 b/src/templates/pools.yaml.j2 new file mode 100644 index 0000000..b644b05 --- /dev/null +++ b/src/templates/pools.yaml.j2 @@ -0,0 +1,27 @@ +- id: 794ccc2c-d751-44fe-b57f-8894c9f5c842 + name: default + description: Pool generated by Juju + +{%- if dns_backend.ns_records %} + ns_records: +{%- for record in dns_backend.ns_records %} + - hostname: {{ record }} + priority: 10 +{%- endfor %} +{%- endif %} + + nameservers: + - host: {{ dns_backend.host }} + port: 53 + + targets: + - type: bind9 + masters: + - host: {{ dns_backend.mdns_ip }} + port: 5354 + options: + host: {{ dns_backend.host }} + port: 53 + rndc_host: {{ dns_backend.host }} + rndc_port: 953 + rndc_key_file: {{ dns_backend.rndc_file_key }} diff --git a/src/templates/rndc.key.j2 b/src/templates/rndc.key.j2 new file mode 100644 index 0000000..813eb42 --- /dev/null +++ b/src/templates/rndc.key.j2 @@ -0,0 +1,4 @@ +key "{{ dns_backend.rndc_key["name"] }}" { + algorithm {{ dns_backend.rndc_key["algorithm"] }}; + secret "{{ dns_backend.rndc_key["secret"] }}"; +}; diff --git a/src/templates/wsgi-designate-api.conf.j2 b/src/templates/wsgi-designate-api.conf.j2 new file mode 100644 index 0000000..c9def84 --- /dev/null +++ b/src/templates/wsgi-designate-api.conf.j2 @@ -0,0 +1,28 @@ +Listen {{ wsgi_config.public_port }} + + + WSGIDaemonProcess {{ wsgi_config.group }} processes=3 threads=1 user={{ wsgi_config.user }} group={{ wsgi_config.group }} \ + display-name=%{GROUP} + WSGIProcessGroup {{ wsgi_config.group }} + {% if ingress_public.ingress_path -%} + WSGIScriptAlias {{ ingress_public.ingress_path }} {{ wsgi_config.wsgi_public_script }} + {% endif -%} + WSGIScriptAlias / {{ wsgi_config.wsgi_public_script }} + WSGIApplicationGroup %{GLOBAL} + WSGIPassAuthorization On + = 2.4> + ErrorLogFormat "%{cu}t %M" + + ErrorLog {{ wsgi_config.error_log }} + CustomLog {{ wsgi_config.custom_log }} combined + + + = 2.4> + Require all granted + + + Order allow,deny + Allow from all + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..f33eee5 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,10 @@ +# This file is managed centrally. If you find the need to modify this as a +# one-off, please don't. Intead, consult #openstack-charms and ask about +# requirements management in charms via bot-control. Thank you. + +coverage +mock +flake8 +stestr +ops +pytest-mock diff --git a/tests/bundles/smoke.yaml b/tests/bundles/smoke.yaml new file mode 100644 index 0000000..62ff9b0 --- /dev/null +++ b/tests/bundles/smoke.yaml @@ -0,0 +1,69 @@ +bundle: kubernetes +applications: + mysql: + charm: ch:mysql-k8s + channel: 8.0/stable + scale: 1 + trust: true + + # Currently traefik is required for networking things. + # If this isn't present, the units will hang at "installing agent". + traefik: + charm: ch:traefik-k8s + channel: 1.0/stable + scale: 1 + trust: true + options: + kubernetes-service-annotations: metallb.universe.tf/address-pool=public + + rabbitmq: + charm: ch:rabbitmq-k8s + channel: 3.9/edge + scale: 1 + trust: true + + keystone: + charm: ch:keystone-k8s + channel: 2023.1/edge + scale: 1 + trust: false + options: + admin-role: admin + storage: + fernet-keys: 5M + credential-keys: 5M + + bind9: + charm: ch:bind9-k8s + channel: latest/edge + scale: 1 + trust: false + + designate: + charm: ../../designate-k8s.charm + scale: 1 + trust: false + resources: + designate-image: ghcr.io/canonical/designate-consolidated:2023.1 + +relations: +- - traefik:ingress + - keystone:ingress-internal +- - traefik:ingress + - keystone:ingress-public + +- - mysql:database + - keystone:database + +- - mysql:database + - designate:database +- - rabbitmq:amqp + - designate:amqp +- - keystone:identity-service + - designate:identity-service +- - traefik:ingress + - designate:ingress-internal +- - traefik:ingress + - designate:ingress-public +- - bind9:dns-backend + - designate:dns-backend diff --git a/tests/config.yaml b/tests/config.yaml new file mode 120000 index 0000000..e84e89a --- /dev/null +++ b/tests/config.yaml @@ -0,0 +1 @@ +../config.yaml \ No newline at end of file diff --git a/tests/tests.yaml b/tests/tests.yaml new file mode 100644 index 0000000..b796c25 --- /dev/null +++ b/tests/tests.yaml @@ -0,0 +1,39 @@ +gate_bundles: + - smoke +smoke_bundles: + - smoke +# There is no storage provider at the moment so cannot run tests. +configure: + - zaza.charm_tests.noop.setup.basic_setup +tests: + - zaza.openstack.charm_tests.designate.tests.DesignateTempestTestK8S +tests_options: + trust: + - smoke + ignore_hard_deploy_errors: + - smoke + + tempest: + default: + smoke: True + +target_deploy_status: + traefik: + workload-status: active + workload-status-message-regex: '^$' + rabbitmq: + workload-status: active + workload-status-message-regex: '^$' + keystone: + workload-status: active + workload-status-message-regex: '^$' + mysql: + workload-status: active + workload-status-message-regex: '^.*$' + bind9: + workload-status: active + workload-status-message-regex: '^.*$' + designate: + workload-status: active + workload-status-message-regex: '^.*$' + diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..d6aa65c --- /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 Designate operator.""" diff --git a/tests/unit/test_designate_charm.py b/tests/unit/test_designate_charm.py new file mode 100644 index 0000000..f8144b8 --- /dev/null +++ b/tests/unit/test_designate_charm.py @@ -0,0 +1,155 @@ +#!/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. + +"""Unit tests for Designate operator.""" + +import json +from unittest.mock import ( + patch, +) + +import ops_sunbeam.test_utils as test_utils +from ops.testing import ( + Harness, +) + +import charm + + +class _DesignateTestOperatorCharm(charm.DesignateOperatorCharm): + """Test Operator Charm for Designate Operator.""" + + def __init__(self, framework): + self.seen_events = [] + super().__init__(framework) + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + def configure_charm(self, event): + super().configure_charm(event) + self._log_event(event) + + @property + def public_ingress_address(self): + return "designate.juju" + + +class TestDesignateOperatorCharm(test_utils.CharmTestCase): + """Unit tests for Designate Operator.""" + + PATCHES = [] + + def setUp(self): + """Set up environment for unit test.""" + super().setUp(charm, self.PATCHES) + self.harness = test_utils.get_harness( + _DesignateTestOperatorCharm, container_calls=self.container_calls + ) + + # clean up events that were dynamically defined, + # otherwise we get issues because they'll be redefined, + # which is not allowed. + from charms.data_platform_libs.v0.database_requires import ( + DatabaseEvents, + ) + + for attr in ( + "database_database_created", + "database_endpoints_changed", + "database_read_only_endpoints_changed", + ): + try: + delattr(DatabaseEvents, attr) + except AttributeError: + pass + + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def test_pebble_ready_handler(self): + """Test pebble ready handler.""" + self.assertEqual(self.harness.charm.seen_events, []) + test_utils.set_all_pebbles_ready(self.harness) + self.assertEqual(len(self.harness.charm.seen_events), 1) + + @patch( + "secrets.token_hex", + ) + def test_all_relations(self, token_hex): + """Test all integrations for operator.""" + token_hex.return_value = "abfcdfea12" + self.harness.set_leader() + test_utils.set_all_pebbles_ready(self.harness) + # this adds all the default/common relations + test_utils.add_all_relations(self.harness) + test_utils.add_complete_ingress_relation(self.harness) + rel_id = add_base_rndc_relation(self.harness) + add_rndc_relation_credentials(self.harness, rel_id) + + setup_cmds = [ + ["a2ensite", "wsgi-designate-api"], + [ + "sudo", + "-u", + "designate", + "designate-manage", + "database", + "sync", + ], + [ + "sudo", + "-u", + "designate", + "designate-manage", + "pool", + "update", + ], + ] + for cmd in setup_cmds: + self.assertIn(cmd, self.container_calls.execute["designate"]) + config_files = [ + "/etc/apache2/sites-available/wsgi-designate-api.conf", + "/etc/designate/designate.conf", + "/etc/designate/pools.yaml", + "/etc/designate/rndc.key", + ] + for f in config_files: + self.check_file("designate", f) + + +def add_base_rndc_relation(harness: Harness) -> int: + """Add amqp relation.""" + rel_id = harness.add_relation("dns-backend", "bind9") + harness.add_relation_unit(rel_id, "bind9/0") + return rel_id + + +def add_rndc_relation_credentials(harness: Harness, rel_id: int) -> None: + """Add amqp data to amqp relation.""" + secret = harness.add_model_secret("bind9", {"secret": "rndc_secret"}) + harness.grant_secret(secret, "designate-k8s") + nonce = harness.get_relation_data(rel_id, "designate-k8s/0").get("nonce") + harness.update_relation_data( + rel_id, + "bind9", + { + "host": "10.20.20.20", + "rndc_keys": json.dumps( + {nonce: {"algorithm": "hmac-256", "secret": secret}} + ), + }, + ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..0bc536c --- /dev/null +++ b/tox.ini @@ -0,0 +1,161 @@ +# Operator charm (with zaza): tox.ini + +[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 = + HOME + 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