commit 8c03b452ba917af04a08f46d0a2e6f4bdbb89720 Author: Guillaume Boutry Date: Thu Aug 24 10:43:57 2023 +0200 Initial commit diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8ef84fc --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +max-line-length = 99 +select: E,W,F,C,N +exclude: + venv + .git + build + dist + *.egg_info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33d25ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea +.vscode/ +.stestr/ diff --git a/.gitreview b/.gitreview new file mode 100644 index 0000000..6bdf940 --- /dev/null +++ b/.gitreview @@ -0,0 +1,5 @@ +[gerrit] +host=review.opendev.org +port=29418 +project=openstack/charm-magnum-k8s.git +defaultbranch=main diff --git a/.jujuignore b/.jujuignore new file mode 100644 index 0000000..6ccd559 --- /dev/null +++ b/.jujuignore @@ -0,0 +1,3 @@ +/venv +*.py[cod] +*.charm 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-magnum-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-magnum-k8s]: https://bugs.launchpad.net/charm-magnum-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..ca87c15 --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,19 @@ +type: "charm" +bases: + - build-on: + - name: "ubuntu" + channel: "22.04" + run-on: + - name: "ubuntu" + channel: "22.04" +parts: + charm: + build-packages: + - git + - libffi-dev + - libssl-dev + 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..758fa03 --- /dev/null +++ b/config.yaml @@ -0,0 +1,27 @@ +options: + debug: + default: False + description: Enable debug logging. + type: boolean + os-admin-hostname: + default: magnum.juju + description: | + The hostname or address of the admin endpoints that should be advertised + in the glance image provider. + type: string + os-internal-hostname: + default: magnum.juju + description: | + The hostname or address of the internal endpoints that should be advertised + in the glance image provider. + type: string + os-public-hostname: + default: magnum.juju + description: | + The hostname or address of the internal endpoints that should be advertised + in the glance image provider. + type: string + region: + default: RegionOne + description: Space delimited list of OpenStack regions + type: string diff --git a/fetch-libs.sh b/fetch-libs.sh new file mode 100755 index 0000000..bde3256 --- /dev/null +++ b/fetch-libs.sh @@ -0,0 +1,8 @@ +#!/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.keystone_k8s.v0.identity_resource +charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq +charmcraft fetch-lib charms.traefik_k8s.v1.ingress 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/v0/identity_resource.py b/lib/charms/keystone_k8s/v0/identity_resource.py new file mode 100644 index 0000000..6ef944e --- /dev/null +++ b/lib/charms/keystone_k8s/v0/identity_resource.py @@ -0,0 +1,373 @@ +"""IdentityResourceProvides and Requires module. + + +This library contains the Requires and Provides classes for handling +the identity_ops interface. + +Import `IdentityResourceRequires` in your charm, with the charm object and the +relation name: + - self + - "identity_ops" + +Also provide additional parameters to the charm object: + - request + +Three events are also available to respond to: + - provider_ready + - provider_goneaway + - response_avaialable + +A basic example showing the usage of this relation follows: + +``` +from charms.keystone_k8s.v0.identity_resource import IdentityResourceRequires + +class IdentityResourceClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # IdentityResource Requires + self.identity_resource = IdentityResourceRequires( + self, "identity_ops", + ) + self.framework.observe( + self.identity_resource.on.provider_ready, self._on_identity_resource_ready) + self.framework.observe( + self.identity_resource.on.provider_goneaway, self._on_identity_resource_goneaway) + self.framework.observe( + self.identity_resource.on.response_available, self._on_identity_resource_response) + + def _on_identity_resource_ready(self, event): + '''React to the IdentityResource provider_ready event. + + This event happens when n IdentityResource relation is added to the + model. Ready to send any ops to keystone. + ''' + # Ready to send any ops. + pass + + def _on_identity_resource_response(self, event): + '''React to the IdentityResource response_available event. + + The IdentityResource interface will provide the response for the ops sent. + ''' + # Read the response for the ops sent. + pass + + def _on_identity_resource_goneaway(self, event): + '''React to the IdentityResource goneaway event. + + This event happens when an IdentityResource relation is removed. + ''' + # IdentityResource Relation has goneaway. No ops can be sent. + pass +``` + +A sample ops request can be of format +{ + "id": + "tag": + "ops": [ + { + "name": , + "params": { + : , + : + } + } + ] +} + +For any sensitive data in the ops params, the charm can create secrets and pass +secret id instead of sensitive data as part of ops request. The charm should +ensure to grant secret access to provider charm i.e., keystone over relation. +The secret content should hold the sensitive data with same name as param name. +""" + +import json +import logging + +from ops.charm import ( + RelationEvent, +) +from ops.framework import ( + EventBase, + EventSource, + Object, + ObjectEvents, + StoredState, +) +from ops.model import ( + Relation, +) + +logger = logging.getLogger(__name__) + + +# The unique Charmhub library identifier, never change it +LIBID = "b419d4d8249e423487daafc3665ed06f" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 2 + + +REQUEST_NOT_SENT = 1 +REQUEST_SENT = 2 +REQUEST_PROCESSED = 3 + + +class IdentityOpsProviderReadyEvent(RelationEvent): + """Has IdentityOpsProviderReady Event.""" + + pass + + +class IdentityOpsResponseEvent(RelationEvent): + """Has IdentityOpsResponse Event.""" + + pass + + +class IdentityOpsProviderGoneAwayEvent(RelationEvent): + """Has IdentityOpsProviderGoneAway Event.""" + + pass + + +class IdentityResourceResponseEvents(ObjectEvents): + """Events class for `on`.""" + + provider_ready = EventSource(IdentityOpsProviderReadyEvent) + response_available = EventSource(IdentityOpsResponseEvent) + provider_goneaway = EventSource(IdentityOpsProviderGoneAwayEvent) + + +class IdentityResourceRequires(Object): + """IdentityResourceRequires class.""" + + on = IdentityResourceResponseEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self._stored.set_default(provider_ready=False, requests=[]) + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_resource_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_resource_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_resource_relation_broken, + ) + + def _on_identity_resource_relation_joined(self, event): + """Handle IdentityResource joined.""" + self._stored.provider_ready = True + self.on.provider_ready.emit(event.relation) + + def _on_identity_resource_relation_changed(self, event): + """Handle IdentityResource changed.""" + id_ = self.response.get("id") + self.save_request_in_store(id_, None, None, REQUEST_PROCESSED) + self.on.response_available.emit(event.relation) + + def _on_identity_resource_relation_broken(self, event): + """Handle IdentityResource broken.""" + self._stored.provider_ready = False + self.on.provider_goneaway.emit(event.relation) + + @property + def _identity_resource_rel(self) -> Relation: + """The IdentityResource relation.""" + return self.framework.model.get_relation(self.relation_name) + + @property + def response(self) -> dict: + """Response object from keystone.""" + response = self.get_remote_app_data("response") + if not response: + return {} + + try: + return json.loads(response) + except Exception as e: + logger.debug(str(e)) + + return {} + + def save_request_in_store(self, id: str, tag: str, ops: list, state: int): + """Save request in the store.""" + if id is None: + return + + for request in self._stored.requests: + if request.get("id") == id: + if tag: + request["tag"] = tag + if ops: + request["ops"] = ops + request["state"] = state + return + + # New request + self._stored.requests.append( + {"id": id, "tag": tag, "ops": ops, "state": state} + ) + + def get_request_from_store(self, id: str) -> dict: + """Get request from the stote.""" + for request in self._stored.requests: + if request.get("id") == id: + return request + + return {} + + def is_request_processed(self, id: str) -> bool: + """Check if request is processed.""" + for request in self._stored.requests: + if ( + request.get("id") == id + and request.get("state") == REQUEST_PROCESSED + ): + return True + + return False + + def get_remote_app_data(self, key: str) -> str: + """Return the value for the given key from remote app data.""" + data = self._identity_resource_rel.data[ + self._identity_resource_rel.app + ] + return data.get(key) + + def ready(self) -> bool: + """Interface is ready or not. + + Interface is considered ready if the op request is processed + and response is sent. In case of non leader unit, just consider + the interface is ready. + """ + if not self.model.unit.is_leader(): + logger.debug("Not a leader unit, set the interface to ready") + return True + + try: + app_data = self._identity_resource_rel.data[self.charm.app] + if "request" not in app_data: + return False + + request = json.loads(app_data["request"]) + request_id = request.get("id") + response_id = self.response.get("id") + if request_id == response_id: + return True + except Exception as e: + logger.debug(str(e)) + + return False + + def request_ops(self, request: dict) -> None: + """Request keystone ops.""" + if not self.model.unit.is_leader(): + logger.debug("Not a leader unit, not sending request") + return + + id_ = request.get("id") + tag = request.get("tag") + ops = request.get("ops") + req = self.get_request_from_store(id_) + if req and req.get("state") == REQUEST_PROCESSED: + logger.debug("Request {id_} already processed") + return + + if not self._stored.provider_ready: + self.save_request_in_store(id_, tag, ops, REQUEST_NOT_SENT) + logger.debug("Keystone not yet ready to take requests") + return + + logger.debug("Requesting ops to keystone") + app_data = self._identity_resource_rel.data[self.charm.app] + app_data["request"] = json.dumps(request) + self.save_request_in_store(id_, tag, ops, REQUEST_SENT) + + +class IdentityOpsRequestEvent(EventBase): + """Has IdentityOpsRequest Event.""" + + def __init__(self, handle, relation_id, relation_name, request): + """Initialise event.""" + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.request = request + + def snapshot(self): + """Snapshot the event.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "request": self.request, + } + + def restore(self, snapshot): + """Restore the event.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.request = snapshot["request"] + + +class IdentityResourceProviderEvents(ObjectEvents): + """Events class for `on`.""" + + process_op = EventSource(IdentityOpsRequestEvent) + + +class IdentityResourceProvides(Object): + """IdentityResourceProvides class.""" + + on = IdentityResourceProviderEvents() + + 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_changed, + self._on_identity_resource_relation_changed, + ) + + def _on_identity_resource_relation_changed(self, event): + """Handle IdentityResource changed.""" + request = event.relation.data[event.relation.app].get("request", {}) + self.on.process_op.emit( + event.relation.id, event.relation.name, request + ) + + def set_ops_response( + self, relation_id: str, relation_name: str, ops_response: dict + ): + """Set response to ops request.""" + if not self.model.unit.is_leader(): + logger.debug("Not a leader unit, not sending response") + return + + logger.debug("Update response from keystone") + _identity_resource_rel = self.charm.model.get_relation( + relation_name, relation_id + ) + if not _identity_resource_rel: + # Relation has disappeared so skip send of data + return + + app_data = _identity_resource_rel.data[self.charm.app] + app_data["response"] = json.dumps(ops_response) 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..ed063a3 --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,57 @@ +name: magnum-k8s +summary: OpenStack magnum service +maintainer: OpenStack Charmers +description: | + Magnum is an OpenStack project which offers container orchestration engines + for deploying and managing containers as first class resources in OpenStack. +version: 3 +bases: + - name: ubuntu + channel: 22.04/stable +assumes: + - k8s-api + - juju >= 3.1 +tags: + - openstack + - container-orchestration + - misc + +containers: + magnum-api: + resource: magnum-api-image + magnum-conductor: + resource: magnum-conductor-image + +resources: + magnum-api-image: + type: oci-image + description: OCI image for OpenStack magnum + # ghcr.io/openstack-snaps/magnum-consolidated:2023.1 + upstream-source: ghcr.io/openstack-snaps/magnum-consolidated:2023.1 + magnum-conductor-image: + type: oci-image + description: OCI image for OpenStack magnum + # ghcr.io/openstack-snaps/magnum-consolidated:2023.1 + upstream-source: ghcr.io/openstack-snaps/magnum-consolidated:2023.1 + +requires: + database: + interface: mysql_client + limit: 1 + identity-service: + interface: keystone + identity-ops: + interface: keystone-resources + ingress-internal: + interface: ingress + optional: true + limit: 1 + ingress-public: + interface: ingress + limit: 1 + amqp: + interface: rabbitmq + +peers: + peers: + interface: magnum-peer diff --git a/osci.yaml b/osci.yaml new file mode 100644 index 0000000..e1ae975 --- /dev/null +++ b/osci.yaml @@ -0,0 +1,10 @@ +- project: + templates: + - charm-publish-jobs + vars: + needs_charm_build: true + charm_build_name: magnum-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..e6de53b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +# Copyright 2022 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..2311bda --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +ops +jinja2 +git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam +lightkube +pwgen diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..fa23c3a --- /dev/null +++ b/src/charm.py @@ -0,0 +1,404 @@ +#!/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. +"""Magnum Operator Charm. + +This charm provide Magnum services as part of an OpenStack deployment +""" + +import hashlib +import json +import logging +from typing import ( + TYPE_CHECKING, + List, +) + +import charms.keystone_k8s.v0.identity_resource as identity_resource +import ops_sunbeam.charm as sunbeam_charm +import ops_sunbeam.config_contexts as sunbeam_config_contexts +import ops_sunbeam.container_handlers as sunbeam_chandlers +import ops_sunbeam.core as sunbeam_core +import ops_sunbeam.relation_handlers as sunbeam_rhandlers +import pwgen +from ops.charm import ( + RelationEvent, +) +from ops.framework import ( + StoredState, +) +from ops.main import ( + main, +) +from ops.model import ( + ModelError, + Relation, + SecretNotFoundError, + SecretRotate, +) + +logger = logging.getLogger(__name__) + +CREDENTIALS_SECRET_PREFIX = "credentials_" +MAGNUM_API_CONTAINER = "magnum-api" +MAGNUM_CONDUCTOR_CONTAINER = "magnum-conductor" + + +class MagnumConfigurationContext(sunbeam_config_contexts.ConfigContext): + """Magnum configuration context.""" + + if TYPE_CHECKING: + charm: "MagnumOperatorCharm" + + def context(self) -> dict: + """Magnum configuration context.""" + username, password = self.charm.domain_admin_credentials + return { + "domain_name": self.charm.domain_name, + "domain_admin_user": username, + "domain_admin_password": password, + } + + +class MagnumConductorPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): + """Pebble handler for magnum worker.""" + + def __init__(self, *args, **kwargs): + """Initialize handler.""" + super().__init__(*args, **kwargs) + self.enable_service_check = True + + def get_layer(self) -> dict: + """Magnum conductor service layer. + + :returns: pebble layer configuration for worker service + :rtype: dict + """ + return { + "summary": "magnum worker layer", + "description": "pebble configuration for magnum conductor", + "services": { + "magnum-conductor": { + "override": "replace", + "summary": "magnum conductor", + "command": "magnum-conductor", + "user": "magnum", + "group": "magnum", + } + }, + } + + def default_container_configs( + self, + ) -> List[sunbeam_core.ContainerConfigFile]: + """Container configurations for handler.""" + return [ + sunbeam_core.ContainerConfigFile( + "/etc/magnum/magnum.conf", + "magnum", + "magnum", + ), + sunbeam_core.ContainerConfigFile( + "/etc/magnum/api-paste.ini", + "magnum", + "magnum", + ), + sunbeam_core.ContainerConfigFile( + "/etc/magnum/keystone_auth_default_policy.json", + "magnum", + "magnum", + ), + sunbeam_core.ContainerConfigFile( + "/etc/magnum/policy.json", + "magnum", + "magnum", + ), + ] + + @property + def service_ready(self) -> bool: + """Determine whether the service the container provides is running.""" + if self.enable_service_check: + logging.debug("Service checks enabled for magnum worker") + return super().service_ready + else: + logging.debug("Service checks disabled for magnum worker") + return self.pebble_ready + + +class MagnumOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): + """Charm the service.""" + + _state = StoredState() + service_name = "magnum-api" + wsgi_admin_script = "/usr/bin/magnum-api-wsgi" + wsgi_public_script = "/usr/bin/magnum-api-wsgi" + mandatory_relations = { + "database", + "amqp", + "identity-service", + "ingress-public", + "identity-ops", + } + + db_sync_cmds = [["sudo", "-u", "magnum", "magnum-db-manage", "upgrade"]] + + def __init__(self, *args, **kwargs): + """Initialize charm.""" + super().__init__(*args, **kwargs) + self._state.set_default(identity_ops_ready=False) + + @property + def service_conf(self) -> str: + """Service default configuration file.""" + return "/etc/magnum/magnum.conf" + + @property + def service_user(self) -> str: + """Service user file and directory ownership.""" + return "magnum" + + @property + def service_group(self) -> str: + """Service group file and directory ownership.""" + return "magnum" + + @property + def service_endpoints(self): + """Service endpoints.""" + return [ + { + "service_name": "magnum", + "type": "container-infra", + "description": "OpenStack Magnum API", + "internal_url": self.internal_url, + "public_url": self.public_url, + "admin_url": self.admin_url, + } + ] + + @property + def default_public_ingress_port(self) -> int: + """Default public ingress port.""" + return 9511 + + @property + def config_contexts(self) -> List[sunbeam_config_contexts.ConfigContext]: + """Generate list of configuration adapters for the charm.""" + _cadapters = super().config_contexts + _cadapters.extend([MagnumConfigurationContext(self, "magnum")]) + return _cadapters + + def hash_ops(self, ops: list) -> str: + """Return the sha1 of the requested ops.""" + return hashlib.sha1(json.dumps(ops).encode()).hexdigest() + + def get_relation_handlers(self) -> List[sunbeam_rhandlers.RelationHandler]: + """Relation handlers for the service.""" + handlers = super().get_relation_handlers() + self.id_ops = sunbeam_rhandlers.IdentityResourceRequiresHandler( + self, + "identity-ops", + self.handle_keystone_ops, + mandatory="identity-ops" in self.mandatory_relations, + ) + handlers.append(self.id_ops) + return handlers + + def get_pebble_handlers( + self, + ) -> List[sunbeam_chandlers.PebbleHandler]: + """Pebble handlers for operator.""" + pebble_handlers = super().get_pebble_handlers() + pebble_handlers.extend( + [ + MagnumConductorPebbleHandler( + self, + MAGNUM_CONDUCTOR_CONTAINER, + "magnum-conductor", + [], + self.template_dir, + self.configure_charm, + ), + ] + ) + return pebble_handlers + + @property + def domain_name(self) -> str: + """Domain name to create.""" + return "magnum" + + @property + def domain_admin_user(self) -> str: + """User to manage users and projects in domain_name.""" + return "magnum_domain_admin" + + @property + def domain_admin_credentials(self) -> tuple: + """Credentials for domain admin user.""" + credentials_id = self._get_domain_admin_credentials_secret() + credentials = self.model.get_secret(id=credentials_id) + username = credentials.get_content().get("username") + user_password = credentials.get_content().get("password") + return (username, user_password) + + def _get_domain_admin_credentials_secret(self) -> str: + """Get domain admin secret.""" + label = f"{CREDENTIALS_SECRET_PREFIX}{self.domain_admin_user}" + credentials_id = self.peers.get_app_data(label) + + if not credentials_id: + credentials_id = self._retrieve_or_set_secret( + self.domain_admin_user, + ) + + return credentials_id + + def _grant_domain_admin_credentials_secret( + self, + relation: Relation, + ) -> None: + """Grant secret access to the related units.""" + credentials_id = None + try: + credentials_id = self._get_domain_admin_credentials_secret() + secret = self.model.get_secret(id=credentials_id) + logger.debug( + f"Granting access to secret {credentials_id} for relation " + f"{relation.app.name} {relation.name}/{relation.id}" + ) + secret.grant(relation) + except (ModelError, SecretNotFoundError) as e: + logger.debug( + f"Error during granting access to secret {credentials_id} for " + f"relation {relation.app.name} {relation.name}/{relation.id}: " + f"{str(e)}" + ) + + def _retrieve_or_set_secret( + self, + username: str, + rotate: SecretRotate = SecretRotate.NEVER, + add_suffix_to_username: bool = False, + ) -> str: + """Retrieve or create a secret.""" + label = f"{CREDENTIALS_SECRET_PREFIX}{username}" + credentials_id = self.peers.get_app_data(label) + if credentials_id: + return credentials_id + + password = pwgen.pwgen(12) + if add_suffix_to_username: + suffix = pwgen.pwgen(6) + username = f"{username}-{suffix}" + credentials_secret = self.model.app.add_secret( + {"username": username, "password": password}, + label=label, + rotate=rotate, + ) + self.peers.set_app_data( + { + label: credentials_secret.id, + } + ) + return credentials_secret.id + + def _get_magnum_domain_ops(self) -> list: + """Generate ops request for domain setup.""" + credentials_id = self._get_domain_admin_credentials_secret() + ops = [ + # Create domain magnum + { + "name": "create_domain", + "params": {"name": "magnum", "enable": True}, + }, + # Create role magnum_domain_admin + {"name": "create_role", "params": {"name": "magnum_domain_admin"}}, + # Create user magnum + { + "name": "create_user", + "params": { + "name": self.domain_admin_user, + "password": credentials_id, + "domain": "magnum", + }, + }, + # Grant role admin to magnum_domain_admin user + { + "name": "grant_role", + "params": { + "role": "admin", + "domain": "magnum", + "user": self.domain_admin_user, + "user_domain": "magnum", + }, + }, + ] + return ops + + def _handle_initial_magnum_domain_setup_response( + self, + event: RelationEvent, + ) -> None: + """Handle domain setup response from identity-ops.""" + if { + op.get("return-code") + for op in self.id_ops.interface.response.get( + "ops", + [], + ) + } == {0}: + logger.debug( + "Initial magnum domain setup commands completed," + " running configure charm" + ) + self.configure_charm(event) + + def handle_keystone_ops(self, event: RelationEvent) -> None: + """Event handler for identity ops.""" + if isinstance(event, identity_resource.IdentityOpsProviderReadyEvent): + self._state.identity_ops_ready = True + + if not self.unit.is_leader(): + return + + # Send op request only by leader unit + ops = self._get_magnum_domain_ops() + id_ = self.hash_ops(ops) + self._grant_domain_admin_credentials_secret(event.relation) + request = { + "id": id_, + "tag": "initial_magnum_domain_setup", + "ops": ops, + } + logger.debug(f"Sending ops request: {request}") + self.id_ops.interface.request_ops(request) + elif isinstance( + event, + identity_resource.IdentityOpsProviderGoneAwayEvent, + ): + self._state.identity_ops_ready = False + elif isinstance(event, identity_resource.IdentityOpsResponseEvent): + if not self.unit.is_leader(): + return + response = self.id_ops.interface.response + logger.debug(f"Got response from keystone: {response}") + request_tag = response.get("tag") + if request_tag == "initial_magnum_domain_setup": + self._handle_initial_magnum_domain_setup_response(event) + + +if __name__ == "__main__": + main(MagnumOperatorCharm) diff --git a/src/templates/api-paste.ini.j2 b/src/templates/api-paste.ini.j2 new file mode 100644 index 0000000..058b04a --- /dev/null +++ b/src/templates/api-paste.ini.j2 @@ -0,0 +1,36 @@ +[composite:main] +paste.composite_factory = magnum.api:root_app_factory +/: api +/healthcheck: healthcheck +{% if ingress_public.ingress_path -%} +{{ ingress_public.ingress_path }}: api +{% endif %} + +[pipeline:api] +pipeline = cors http_proxy_to_wsgi request_id osprofiler authtoken api_v1 + +[app:api_v1] +paste.app_factory = magnum.api.app:app_factory + +[filter:authtoken] +acl_public_routes = /, /v1 +paste.filter_factory = magnum.api.middleware.auth_token:AuthTokenMiddleware.factory + +[filter:osprofiler] +paste.filter_factory = magnum.common.profiler:WsgiMiddleware.factory + +[filter:request_id] +paste.filter_factory = oslo_middleware:RequestId.factory + +[filter:cors] +paste.filter_factory = oslo_middleware.cors:filter_factory +oslo_config_project = magnum + +[app:healthcheck] +paste.app_factory = oslo_middleware:Healthcheck.app_factory +backends = disable_by_file +disable_by_file_path = /etc/magnum/healthcheck_disable + +[filter:http_proxy_to_wsgi] +paste.filter_factory = oslo_middleware.http_proxy_to_wsgi:HTTPProxyToWSGI.factory +oslo_config_project = magnum diff --git a/src/templates/keystone_auth_default_policy.json.j2 b/src/templates/keystone_auth_default_policy.json.j2 new file mode 100644 index 0000000..3796eb7 --- /dev/null +++ b/src/templates/keystone_auth_default_policy.json.j2 @@ -0,0 +1,76 @@ +[ + { + "users":{ + "roles":[ + "k8s_admin" + ], + "projects":[ + "$PROJECT_ID" + ] + }, + "resource_permissions":{ + "*/*":[ + "*" + ] + }, + "nonresource_permissions":{ + "/healthz":[ + "get", + "post" + ] + } + }, + { + "users":{ + "roles":[ + "k8s_developer" + ], + "projects":[ + "$PROJECT_ID" + ] + }, + "resource_permissions":{ + "!kube-system/['apiServices', 'bindings', 'componentstatuses', 'configmaps', 'cronjobs', 'customResourceDefinitions', 'deployments', 'endpoints', 'events', 'horizontalPodAutoscalers', 'ingresses', 'initializerConfigurations', 'jobs', 'limitRanges', 'localSubjectAccessReviews', 'namespaces', 'networkPolicies', 'persistentVolumeClaims', 'persistentVolumes', 'podDisruptionBudgets', 'podPresets', 'podTemplates', 'pods', 'replicaSets', 'replicationControllers', 'resourceQuotas', 'secrets', 'selfSubjectAccessReviews', 'serviceAccounts', 'services', 'statefulSets', 'storageClasses', 'subjectAccessReviews', 'tokenReviews']":[ + "*" + ], + "*/['clusterrolebindings', 'clusterroles', 'rolebindings', 'roles', 'controllerrevisions', 'nodes', 'podSecurityPolicies']":[ + "get", + "list", + "watch" + ], + "*/['certificateSigningRequests']":[ + "create", + "delete", + "get", + "list", + "watch", + "update" + ] + } + }, + { + "users":{ + "roles":[ + "k8s_viewer" + ], + "projects":[ + "$PROJECT_ID" + ] + }, + "resource_permissions":{ + "!kube-system/['tokenReviews']":[ + "*" + ], + "!kube-system/['apiServices', 'bindings', 'componentstatuses', 'configmaps', 'cronjobs', 'customResourceDefinitions', 'deployments', 'endpoints', 'events', 'horizontalPodAutoscalers', 'ingresses', 'initializerConfigurations', 'jobs', 'limitRanges', 'localSubjectAccessReviews', 'namespaces', 'networkPolicies', 'persistentVolumeClaims', 'persistentVolumes', 'podDisruptionBudgets', 'podPresets', 'podTemplates', 'pods', 'replicaSets', 'replicationControllers', 'resourceQuotas', 'secrets', 'selfSubjectAccessReviews', 'serviceAccounts', 'services', 'statefulSets', 'storageClasses', 'subjectAccessReviews']":[ + "get", + "list", + "watch" + ], + "*/['clusterrolebindings', 'clusterroles', 'rolebindings', 'roles', 'controllerrevisions', 'nodes', 'podSecurityPolicies']":[ + "get", + "list", + "watch" + ] + } + } +] diff --git a/src/templates/magnum.conf.j2 b/src/templates/magnum.conf.j2 new file mode 100644 index 0000000..9ce70e9 --- /dev/null +++ b/src/templates/magnum.conf.j2 @@ -0,0 +1,27 @@ +[DEFAULT] +debug = {{ options.debug }} +lock_path = /var/lock/magnum +state_path = /var/lib/magnum + +transport_url = {{ amqp.transport_url }} + +{% include "parts/database-connection" %} +db_auto_create = false + +{% include "parts/section-identity" %} + +{% include "parts/section-service-user" %} + +{% include "parts/section-trust" %} + +[oslo_messaging_notifications] +driver = messaging + +# Update region name from relations + +[cinder_client] +endpoint_type = adminURL +region_name = RegionOne + +[api] +api_paste_config=/etc/magnum/api-paste.ini diff --git a/src/templates/parts/database-connection b/src/templates/parts/database-connection new file mode 100644 index 0000000..058d99a --- /dev/null +++ b/src/templates/parts/database-connection @@ -0,0 +1,3 @@ +{% if database.connection -%} +sql_connection = {{ database.connection }} +{% endif -%} diff --git a/src/templates/parts/identity-data b/src/templates/parts/identity-data new file mode 100644 index 0000000..db28064 --- /dev/null +++ b/src/templates/parts/identity-data @@ -0,0 +1,26 @@ +{% 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 + +# XXX Region should come from the id relation here +region_name = {{ options.region }} diff --git a/src/templates/parts/section-certificates b/src/templates/parts/section-certificates new file mode 100644 index 0000000..ddc9b31 --- /dev/null +++ b/src/templates/parts/section-certificates @@ -0,0 +1,2 @@ +[certificates] +cert_manager_type = barbican diff --git a/src/templates/parts/section-database b/src/templates/parts/section-database new file mode 100644 index 0000000..986d9b1 --- /dev/null +++ b/src/templates/parts/section-database @@ -0,0 +1,3 @@ +[database] +{% include "parts/database-connection" %} +connection_recycle_time = 200 diff --git a/src/templates/parts/section-identity b/src/templates/parts/section-identity new file mode 100644 index 0000000..b45ab19 --- /dev/null +++ b/src/templates/parts/section-identity @@ -0,0 +1,5 @@ +[keystone_authtoken] +{% include "parts/identity-data" %} + +[keystone_auth] +{% include "parts/identity-data" %} 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-trust b/src/templates/parts/section-trust new file mode 100644 index 0000000..e5cfca7 --- /dev/null +++ b/src/templates/parts/section-trust @@ -0,0 +1,5 @@ +[trust] +trustee_domain_name = {{ magnum.domain_name }} +trustee_domain_admin_name = {{ magnum.domain_admin_user }} +trustee_domain_admin_password = {{ magnum.domain_admin_password }} +trustee_keystone_interface = admin diff --git a/src/templates/policy.json.j2 b/src/templates/policy.json.j2 new file mode 100644 index 0000000..a5b5070 --- /dev/null +++ b/src/templates/policy.json.j2 @@ -0,0 +1,66 @@ +{ + "context_is_admin": "role:admin", + "admin_or_owner": "is_admin:True or project_id:%(project_id)s", + "admin_api": "rule:context_is_admin", + "admin_or_user": "is_admin:True or user_id:%(user_id)s", + "cluster_user": "user_id:%(trustee_user_id)s", + "deny_cluster_user": "not domain_id:%(trustee_domain_id)s", + "bay:create": "rule:deny_cluster_user", + "bay:delete": "rule:deny_cluster_user", + "bay:detail": "rule:deny_cluster_user", + "bay:get": "rule:deny_cluster_user", + "bay:get_all": "rule:deny_cluster_user", + "bay:update": "rule:deny_cluster_user", + "baymodel:create": "rule:deny_cluster_user", + "baymodel:delete": "rule:deny_cluster_user", + "baymodel:detail": "rule:deny_cluster_user", + "baymodel:get": "rule:deny_cluster_user", + "baymodel:get_all": "rule:deny_cluster_user", + "baymodel:update": "rule:deny_cluster_user", + "baymodel:publish": "rule:admin_api", + "certificate:create": "rule:admin_or_user or rule:cluster_user", + "certificate:get": "rule:admin_or_user or rule:cluster_user", + "certificate:rotate_ca": "rule:admin_or_owner", + "cluster:create": "rule:deny_cluster_user", + "cluster:delete": "rule:deny_cluster_user", + "cluster:delete_all_projects": "rule:admin_api", + "cluster:detail": "rule:deny_cluster_user", + "cluster:detail_all_projects": "rule:admin_api", + "cluster:get": "rule:deny_cluster_user", + "cluster:get_one_all_projects": "rule:admin_api", + "cluster:get_all": "rule:deny_cluster_user", + "cluster:get_all_all_projects": "rule:admin_api", + "cluster:update": "rule:deny_cluster_user", + "cluster:update_all_projects": "rule:admin_api", + "cluster:resize": "rule:deny_cluster_user", + "cluster:upgrade": "rule:deny_cluster_user", + "clustertemplate:create": "rule:deny_cluster_user", + "clustertemplate:delete": "rule:deny_cluster_user", + "clustertemplate:delete_all_projects": "rule:admin_api", + "clustertemplate:detail_all_projects": "rule:admin_api", + "clustertemplate:detail": "rule:deny_cluster_user", + "clustertemplate:get": "rule:deny_cluster_user", + "clustertemplate:get_one_all_projects": "rule:admin_api", + "clustertemplate:get_all": "rule:deny_cluster_user", + "clustertemplate:get_all_all_projects": "rule:admin_api", + "clustertemplate:update": "rule:deny_cluster_user", + "clustertemplate:update_all_projects": "rule:admin_api", + "clustertemplate:publish": "rule:admin_api", + "federation:create": "rule:deny_cluster_user", + "federation:delete": "rule:deny_cluster_user", + "federation:detail": "rule:deny_cluster_user", + "federation:get": "rule:deny_cluster_user", + "federation:get_all": "rule:deny_cluster_user", + "federation:update": "rule:deny_cluster_user", + "magnum-service:get_all": "rule:admin_api", + "quota:create": "rule:admin_api", + "quota:delete": "rule:admin_api", + "quota:get": "rule:admin_or_owner", + "quota:get_all": "rule:admin_api", + "quota:update": "rule:admin_api", + "stats:get_all": "rule:admin_or_owner", + "nodegroup:get": "rule:admin_or_owner", + "nodegroup:get_all": "rule:admin_or_owner", + "nodegroup:get_all_all_projects": "rule:admin_api", + "nodegroup:get_one_all_projects": "rule:admin_api" +} diff --git a/src/templates/wsgi-magnum-api.conf.j2 b/src/templates/wsgi-magnum-api.conf.j2 new file mode 100644 index 0000000..b34c076 --- /dev/null +++ b/src/templates/wsgi-magnum-api.conf.j2 @@ -0,0 +1,27 @@ +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_internal.ingress_path -%} + WSGIScriptAlias {{ ingress_internal.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..8057d2c --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,17 @@ +# 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. +charm-tools>=2.4.4 +coverage>=3.6 +mock>=1.2 +flake8>=2.2.4,<=2.4.1 +pyflakes==2.1.1 +stestr>=2.2.0 +requests>=2.18.4 +psutil +# oslo.i18n dropped py35 support +oslo.i18n<4.0.0 +git+https://github.com/openstack-charmers/zaza.git#egg=zaza +git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack +pytz # workaround for 14.04 pip/tox +pyudev # for ceph-* charm unit tests (not mocked?) diff --git a/tests/bundles/smoke.yaml b/tests/bundles/smoke.yaml new file mode 100644 index 0000000..ad7ee35 --- /dev/null +++ b/tests/bundles/smoke.yaml @@ -0,0 +1,88 @@ +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 + + # required for barbican and magnum + 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 + + vault: + charm: ch:vault-k8s + scale: 1 + trust: false + + barbican: + charm: ch:barbican-k8s + scale: 1 + trust: false + + magnum: + charm: ../../magnum-k8s.charm + scale: 1 + trust: false + resources: + magnum-api-image: ghcr.io/openstack-snaps/magnum-consolidated:2023.1 + magnum-conductor-image: ghcr.io/openstack-snaps/magnum-consolidated:2023.1 + + +relations: +- - traefik:ingress + - keystone:ingress-internal +- - traefik:ingress + - keystone:ingress-public + +- - mysql:database + - keystone:database + +- - mysql:database + - barbican:database +- - rabbitmq:amqp + - barbican:amqp +- - keystone:identity-service + - barbican:identity-service +- - traefik:ingress + - barbican:ingress-internal +- - traefik:ingress + - barbican:ingress-public + +- - vault:secrets + - barbican:secrets + +- - mysql:database + - magnum:database +- - rabbitmq:amqp + - magnum:amqp +- - keystone:identity-service + - magnum:identity-service +- - traefik:ingress + - magnum:ingress-internal +- - traefik:ingress + - magnum:ingress-public 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..b13244c --- /dev/null +++ b/tests/tests.yaml @@ -0,0 +1,44 @@ +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.magnum.tests.magnumTempestTestK8S +tests_options: + trust: + - smoke + ignore_hard_deploy_errors: + - smoke + + tempest: + default: + smoke: True + +target_deploy_status: + traefik: + workload-status: active + workload-status-message-regex: '^$' + traefik-public: + 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: '^.*$' + barbican: + workload-status: active + workload-status-message-regex: '^$' + vault: + workload-status: active + workload-status-message-regex: '^$' + magnum: + 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..3d1aa55 --- /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 Magnum operator.""" diff --git a/tests/unit/test_magnum_charm.py b/tests/unit/test_magnum_charm.py new file mode 100644 index 0000000..4d85d18 --- /dev/null +++ b/tests/unit/test_magnum_charm.py @@ -0,0 +1,139 @@ +#!/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 Magnum operator.""" + +import json + +import ops_sunbeam.test_utils as test_utils +from ops.testing import ( + Harness, +) + +import charm + + +class _MagnumTestOperatorCharm(charm.MagnumOperatorCharm): + """Test Operator Charm for Magnum 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 "magnum.juju" + + +class TestMagnumOperatorCharm(test_utils.CharmTestCase): + """Unit tests for Magnum Operator.""" + + PATCHES = [] + + def setUp(self): + """Set up environment for unit test.""" + super().setUp(charm, self.PATCHES) + self.harness = test_utils.get_harness( + _MagnumTestOperatorCharm, 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 add_complete_identity_resource_relation(self, harness: Harness) -> int: + """Add complete Identity resource relation.""" + rel_id = harness.add_relation("identity-ops", "keystone") + harness.add_relation_unit(rel_id, "keystone/0") + ops = harness.charm._get_magnum_domain_ops() + id_ = harness.charm.hash_ops(ops) + harness.update_relation_data( + rel_id, + "keystone/0", + { + "request": json.dumps( + { + "id": id_, + "tag": "initial_magnum_domain_setup", + "ops": ops, + } + ) + }, + ) + harness.update_relation_data( + rel_id, + "keystone", + { + "response": json.dumps( + { + "id": id_, + "tag": "initial_magnum_domain_setup", + "ops": [{"name": "create_domain", "return-code": 0}], + } + ) + }, + ) + return rel_id + + 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), 2) + + def test_all_relations(self): + """Test all integrations for operator.""" + 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) + self.add_complete_identity_resource_relation(self.harness) + + setup_cmds = [ + ["sudo", "-u", "magnum", "magnum-db-manage", "upgrade"], + ] + for cmd in setup_cmds: + self.assertIn(cmd, self.container_calls.execute["magnum-api"]) + config_files = [ + "/etc/apache2/sites-available/wsgi-magnum-api.conf", + "/etc/magnum/magnum.conf", + ] + for f in config_files: + self.check_file("magnum-api", f) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1b00531 --- /dev/null +++ b/tox.ini @@ -0,0 +1,157 @@ +# Source charm: ./tox.ini +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of tox.ini for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools + +[tox] +skipsdist = True +envlist = pep8,py3 +sitepackages = False +skip_missing_interpreters = False +minversion = 3.18.0 + +[vars] +src_path = {toxinidir}/src/ +tst_path = {toxinidir}/tests/ +lib_path = {toxinidir}/lib/ +pyproject_toml = {toxinidir}/pyproject.toml +all_path = {[vars]src_path} {[vars]tst_path} + +[testenv] +basepython = python3 +setenv = + PYTHONPATH = {toxinidir}:{[vars]lib_path}:{[vars]src_path} +passenv = + PYTHONPATH + HOME +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 + 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 +commands = + functest-run-suite --help + +[testenv:func] +basepython = python3 +commands = + functest-run-suite --keep-model + +[testenv:func-smoke] +basepython = python3 +setenv = + TEST_MODEL_SETTINGS = automatically-retry-hooks=true;default-series= + TEST_MAX_RESOLVE_COUNT = 5 +commands = + functest-run-suite --keep-model --smoke + +[testenv:func-dev] +basepython = python3 +commands = + functest-run-suite --keep-model --dev + +[testenv:func-target] +basepython = python3 +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