From 0fe5f7dfb4071a74ead62a8ee4d66448a3dc5e3b Mon Sep 17 00:00:00 2001 From: Liam Young Date: Wed, 26 Jul 2023 11:28:04 +0000 Subject: [PATCH] Add ops.scenario tests Add ops.scenario tests. This allows each charm class to be easily tested with different permutations of missing/incomplete/complete relations. This is a starting point for using ops.scenario, additional tests should include: examining rendered files, peer relation, test secrets events etc Change-Id: I8ebdad250d7cb169c3c0d72858e0582000d98b6e --- ops-sunbeam/.stestr.conf | 2 +- ops-sunbeam/fetch-libs.sh | 2 +- ops-sunbeam/test-requirements.txt | 2 + ops-sunbeam/{unit_tests => tests}/__init__.py | 0 .../ceilometer_k8s/v0/ceilometer_service.py | 0 .../charms/cinder_ceph_k8s/v0/ceph_access.py | 0 .../v0/database_requires.py | 77 +++- .../keystone_k8s/v0/identity_credentials.py | 0 .../keystone_k8s/v0/identity_resource.py | 41 +- .../keystone_k8s/v1/identity_service.py | 11 +- .../nginx_ingress_integrator/v0/ingress.py | 416 ++++++++++++++++++ .../lib/charms/ovn_central_k8s/v0/ovsdb.py | 0 .../lib/charms/rabbitmq_k8s/v0/rabbitmq.py | 0 .../lib/charms/traefik_k8s/v1/ingress.py | 111 +++-- ops-sunbeam/tests/scenario_tests/__init__.py | 18 + .../tests/scenario_tests/scenario_utils.py | 142 ++++++ .../tests/scenario_tests/test_fixtures.py | 192 ++++++++ .../tests/scenario_tests/test_scenario.py | 383 ++++++++++++++++ ops-sunbeam/tests/unit_tests/__init__.py | 18 + .../{ => tests}/unit_tests/test_charms.py | 2 +- .../unit_tests/test_compound_status.py | 0 .../{ => tests}/unit_tests/test_core.py | 2 +- .../{ => tests}/unit_tests/test_job_ctrl.py | 0 .../{ => tests}/unit_tests/test_templating.py | 0 ops-sunbeam/tox.ini | 17 +- .../nginx_ingress_integrator/v0/ingress.py | 227 ---------- 26 files changed, 1356 insertions(+), 307 deletions(-) rename ops-sunbeam/{unit_tests => tests}/__init__.py (100%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/ceilometer_k8s/v0/ceilometer_service.py (100%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/cinder_ceph_k8s/v0/ceph_access.py (100%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/data_platform_libs/v0/database_requires.py (90%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/keystone_k8s/v0/identity_credentials.py (100%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/keystone_k8s/v0/identity_resource.py (90%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/keystone_k8s/v1/identity_service.py (98%) create mode 100644 ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py rename ops-sunbeam/{unit_tests => tests}/lib/charms/ovn_central_k8s/v0/ovsdb.py (100%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/rabbitmq_k8s/v0/rabbitmq.py (100%) rename ops-sunbeam/{unit_tests => tests}/lib/charms/traefik_k8s/v1/ingress.py (85%) create mode 100644 ops-sunbeam/tests/scenario_tests/__init__.py create mode 100644 ops-sunbeam/tests/scenario_tests/scenario_utils.py create mode 100644 ops-sunbeam/tests/scenario_tests/test_fixtures.py create mode 100644 ops-sunbeam/tests/scenario_tests/test_scenario.py create mode 100644 ops-sunbeam/tests/unit_tests/__init__.py rename ops-sunbeam/{ => tests}/unit_tests/test_charms.py (99%) rename ops-sunbeam/{ => tests}/unit_tests/test_compound_status.py (100%) rename ops-sunbeam/{ => tests}/unit_tests/test_core.py (99%) rename ops-sunbeam/{ => tests}/unit_tests/test_job_ctrl.py (100%) rename ops-sunbeam/{ => tests}/unit_tests/test_templating.py (100%) delete mode 100644 ops-sunbeam/unit_tests/lib/charms/nginx_ingress_integrator/v0/ingress.py diff --git a/ops-sunbeam/.stestr.conf b/ops-sunbeam/.stestr.conf index 5fcccaca..67c5cf7e 100644 --- a/ops-sunbeam/.stestr.conf +++ b/ops-sunbeam/.stestr.conf @@ -1,3 +1,3 @@ [DEFAULT] -test_path=./unit_tests +test_path=./tests/unit_tests top_dir=./ diff --git a/ops-sunbeam/fetch-libs.sh b/ops-sunbeam/fetch-libs.sh index fe4f743d..c09931dd 100755 --- a/ops-sunbeam/fetch-libs.sh +++ b/ops-sunbeam/fetch-libs.sh @@ -15,4 +15,4 @@ charmcraft fetch-lib charms.traefik_k8s.v1.ingress charmcraft fetch-lib charms.ceilometer_k8s.v0.ceilometer_service charmcraft fetch-lib charms.cinder_ceph_k8s.v0.ceph_access echo "Copying libs to to unit_test dir" -rsync --recursive --delete lib/ unit_tests/lib/ +rsync --recursive --delete lib/ tests/lib/ diff --git a/ops-sunbeam/test-requirements.txt b/ops-sunbeam/test-requirements.txt index 2cb121c8..b196466f 100644 --- a/ops-sunbeam/test-requirements.txt +++ b/ops-sunbeam/test-requirements.txt @@ -2,3 +2,5 @@ coverage mock stestr requests +pytest +ops-scenario>=4.0 diff --git a/ops-sunbeam/unit_tests/__init__.py b/ops-sunbeam/tests/__init__.py similarity index 100% rename from ops-sunbeam/unit_tests/__init__.py rename to ops-sunbeam/tests/__init__.py diff --git a/ops-sunbeam/unit_tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py b/ops-sunbeam/tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py similarity index 100% rename from ops-sunbeam/unit_tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py rename to ops-sunbeam/tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py diff --git a/ops-sunbeam/unit_tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py b/ops-sunbeam/tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py similarity index 100% rename from ops-sunbeam/unit_tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py rename to ops-sunbeam/tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py diff --git a/ops-sunbeam/unit_tests/lib/charms/data_platform_libs/v0/database_requires.py b/ops-sunbeam/tests/lib/charms/data_platform_libs/v0/database_requires.py similarity index 90% rename from ops-sunbeam/unit_tests/lib/charms/data_platform_libs/v0/database_requires.py rename to ops-sunbeam/tests/lib/charms/data_platform_libs/v0/database_requires.py index 39dec6b5..11ffd6ca 100644 --- a/ops-sunbeam/unit_tests/lib/charms/data_platform_libs/v0/database_requires.py +++ b/ops-sunbeam/tests/lib/charms/data_platform_libs/v0/database_requires.py @@ -1,4 +1,4 @@ -# Copyright 2022 Canonical Ltd. +# 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. @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Relation 'requires' side abstraction for database relation. +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, @@ -23,7 +23,10 @@ application charm code: ```python -from charms.data_platform_libs.v0.database_requires import DatabaseRequires +from charms.data_platform_libs.v0.database_requires import ( + DatabaseCreatedEvent, + DatabaseRequires, +) class ApplicationCharm(CharmBase): # Application charm that connects to database charms. @@ -49,7 +52,7 @@ class ApplicationCharm(CharmBase): self._start_application(config_file) # Set active status - self.status.set(ActiveStatus("received database credentials")) + self.unit.status = ActiveStatus("received database credentials") ``` As shown above, the library provides some custom events to handle specific situations, @@ -84,7 +87,10 @@ The implementation would be something like the following code: ```python -from charms.data_platform_libs.v0.database_requires import DatabaseRequires +from charms.data_platform_libs.v0.database_requires import ( + DatabaseCreatedEvent, + DatabaseRequires, +) class ApplicationCharm(CharmBase): # Application charm that connects to database charms. @@ -154,7 +160,7 @@ LIBAPI = 0 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version. -LIBPATCH = 4 +LIBPATCH = 6 logger = logging.getLogger(__name__) @@ -165,16 +171,25 @@ class DatabaseEvent(RelationEvent): @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 @@ -183,16 +198,25 @@ class DatabaseEvent(RelationEvent): 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 @@ -201,11 +225,17 @@ class DatabaseEvent(RelationEvent): 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 @@ -214,6 +244,9 @@ class DatabaseEvent(RelationEvent): Version as informed by the database daemon. """ + if not self.relation.app: + return None + return self.relation.data[self.relation.app].get("version") @@ -253,15 +286,15 @@ A tuple for storing the diff between two data mappings. class DatabaseRequires(Object): """Requires-side of the database relation.""" - on = DatabaseEvents() + on = DatabaseEvents() # pyright: ignore [reportGeneralTypeIssues] def __init__( self, charm, relation_name: str, database_name: str, - extra_user_roles: str = None, - relations_aliases: List[str] = None, + extra_user_roles: Optional[str] = None, + relations_aliases: Optional[List[str]] = None, ): """Manager of database client relations.""" super().__init__(charm, relation_name) @@ -346,9 +379,11 @@ class DatabaseRequires(Object): # 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" - } + 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() @@ -407,9 +442,11 @@ class DatabaseRequires(Object): """ data = {} for relation in self.relations: - data[relation.id] = { - key: value for key, value in relation.data[relation.app].items() if key != "data" - } + 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: @@ -455,7 +492,9 @@ class DatabaseRequires(Object): if "username" in diff.added and "password" in diff.added: # Emit the default event (the one without an alias). logger.info("database created at %s", datetime.now()) - self.on.database_created.emit(event.relation, app=event.app, unit=event.unit) + 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") @@ -469,7 +508,9 @@ class DatabaseRequires(Object): if "endpoints" in diff.added or "endpoints" in diff.changed: # Emit the default event (the one without an alias). logger.info("endpoints changed on %s", datetime.now()) - self.on.endpoints_changed.emit(event.relation, app=event.app, unit=event.unit) + 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") @@ -483,7 +524,7 @@ class DatabaseRequires(Object): if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed: # Emit the default event (the one without an alias). logger.info("read-only-endpoints changed on %s", datetime.now()) - self.on.read_only_endpoints_changed.emit( + getattr(self.on, "read_only_endpoints_changed").emit( event.relation, app=event.app, unit=event.unit ) diff --git a/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_credentials.py b/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_credentials.py similarity index 100% rename from ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_credentials.py rename to ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_credentials.py diff --git a/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_resource.py b/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_resource.py similarity index 90% rename from ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_resource.py rename to ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_resource.py index 2449e0ef..6ef944ef 100644 --- a/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_resource.py +++ b/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_resource.py @@ -61,11 +61,34 @@ class IdentityResourceClientCharm(CharmBase): # 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, @@ -88,7 +111,7 @@ 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 +LIBPATCH = 2 REQUEST_NOT_SENT = 1 @@ -96,19 +119,19 @@ REQUEST_SENT = 2 REQUEST_PROCESSED = 3 -class IdentityOpsProviderReadyEvent(EventBase): +class IdentityOpsProviderReadyEvent(RelationEvent): """Has IdentityOpsProviderReady Event.""" pass -class IdentityOpsResponseEvent(EventBase): +class IdentityOpsResponseEvent(RelationEvent): """Has IdentityOpsResponse Event.""" pass -class IdentityOpsProviderGoneAwayEvent(EventBase): +class IdentityOpsProviderGoneAwayEvent(RelationEvent): """Has IdentityOpsProviderGoneAway Event.""" pass @@ -149,18 +172,18 @@ class IdentityResourceRequires(Object): def _on_identity_resource_relation_joined(self, event): """Handle IdentityResource joined.""" self._stored.provider_ready = True - self.on.provider_ready.emit() + 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() + 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() + self.on.provider_goneaway.emit(event.relation) @property def _identity_resource_rel(self) -> Relation: @@ -339,7 +362,9 @@ class IdentityResourceProvides(Object): return logger.debug("Update response from keystone") - _identity_resource_rel = self.charm.model.get_relation(relation_name, relation_id) + _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 diff --git a/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v1/identity_service.py b/ops-sunbeam/tests/lib/charms/keystone_k8s/v1/identity_service.py similarity index 98% rename from ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v1/identity_service.py rename to ops-sunbeam/tests/lib/charms/keystone_k8s/v1/identity_service.py index 35556622..62dd9a3f 100644 --- a/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v1/identity_service.py +++ b/ops-sunbeam/tests/lib/charms/keystone_k8s/v1/identity_service.py @@ -100,7 +100,7 @@ LIBAPI = 1 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 0 +LIBPATCH = 1 logger = logging.getLogger(__name__) @@ -349,6 +349,11 @@ class IdentityServiceRequires(Object): """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.""" @@ -481,7 +486,8 @@ class IdentityServiceProvides(Object): internal_auth_url: str, admin_auth_url: str, public_auth_url: str, - service_credentials: 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]: @@ -516,3 +522,4 @@ class IdentityServiceProvides(Object): 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/ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py b/ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py new file mode 100644 index 00000000..08dfe45d --- /dev/null +++ b/ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py @@ -0,0 +1,416 @@ +# Copyright 2023 Canonical Ltd. +# Licensed under the Apache2.0. See LICENSE file in charm source for details. +"""Library for the ingress relation. + +This library contains the Requires and Provides classes for handling +the ingress interface. + +Import `IngressRequires` in your charm, with two required options: +- "self" (the charm itself) +- config_dict + +`config_dict` accepts the following keys: +- additional-hostnames +- backend-protocol +- limit-rps +- limit-whitelist +- max-body-size +- owasp-modsecurity-crs +- owasp-modsecurity-custom-rules +- path-routes +- retry-errors +- rewrite-enabled +- rewrite-target +- service-hostname (required) +- service-name (required) +- service-namespace +- service-port (required) +- session-cookie-max-age +- tls-secret-name + +See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions +of each, along with the required type. + +As an example, add the following to `src/charm.py`: +``` +from charms.nginx_ingress_integrator.v0.ingress import IngressRequires + +# In your charm's `__init__` method (assuming your app is listening on port 8080). +self.ingress = IngressRequires(self, { + "service-hostname": self.app.name, + "service-name": self.app.name, + "service-port": 8080, + } +) +``` +And then add the following to `metadata.yaml`: +``` +requires: + ingress: + interface: ingress +``` +You _must_ register the IngressRequires class as part of the `__init__` method +rather than, for instance, a config-changed event handler, for the relation +changed event to be properly handled. + +In the example above we're setting `service-hostname` (which translates to the +external hostname for the application when related to nginx-ingress-integrator) +to `self.app.name` here. This ensures by default the charm will be available on +the name of the deployed juju application, but can be overridden in a +production deployment by setting `service-hostname` on the +nginx-ingress-integrator charm. For example: +```bash +juju deploy nginx-ingress-integrator +juju deploy my-charm +juju relate nginx-ingress-integrator my-charm:ingress +# The service is now reachable on the ingress IP(s) of your k8s cluster at +# 'http://my-charm'. +juju config nginx-ingress-integrator service-hostname='my-charm.example.com' +# The service is now reachable on the ingress IP(s) of your k8s cluster at +# 'http://my-charm.example.com'. +""" + +import copy +import logging +from typing import Dict + +from ops.charm import CharmBase, CharmEvents, RelationBrokenEvent, RelationChangedEvent +from ops.framework import EventBase, EventSource, Object +from ops.model import BlockedStatus + +INGRESS_RELATION_NAME = "ingress" +INGRESS_PROXY_RELATION_NAME = "ingress-proxy" + +# The unique Charmhub library identifier, never change it +LIBID = "db0af4367506491c91663468fb5caa4c" + +# 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 = 17 + +LOGGER = logging.getLogger(__name__) + +REQUIRED_INGRESS_RELATION_FIELDS = {"service-hostname", "service-name", "service-port"} + +OPTIONAL_INGRESS_RELATION_FIELDS = { + "additional-hostnames", + "backend-protocol", + "limit-rps", + "limit-whitelist", + "max-body-size", + "owasp-modsecurity-crs", + "owasp-modsecurity-custom-rules", + "path-routes", + "retry-errors", + "rewrite-target", + "rewrite-enabled", + "service-namespace", + "session-cookie-max-age", + "tls-secret-name", +} + +RELATION_INTERFACES_MAPPINGS = { + "service-hostname": "host", + "service-name": "name", + "service-namespace": "model", + "service-port": "port", +} +RELATION_INTERFACES_MAPPINGS_VALUES = set(RELATION_INTERFACES_MAPPINGS.values()) + + +class IngressAvailableEvent(EventBase): + """IngressAvailableEvent custom event. + + This event indicates the Ingress provider is available. + """ + + +class IngressProxyAvailableEvent(EventBase): + """IngressProxyAvailableEvent custom event. + + This event indicates the IngressProxy provider is available. + """ + + +class IngressBrokenEvent(RelationBrokenEvent): + """IngressBrokenEvent custom event. + + This event indicates the Ingress provider is broken. + """ + + +class IngressCharmEvents(CharmEvents): + """Custom charm events. + + Attrs: + ingress_available: Event to indicate that Ingress is available. + ingress_proxy_available: Event to indicate that IngressProxy is available. + ingress_broken: Event to indicate that Ingress is broken. + """ + + ingress_available = EventSource(IngressAvailableEvent) + ingress_proxy_available = EventSource(IngressProxyAvailableEvent) + ingress_broken = EventSource(IngressBrokenEvent) + + +class IngressRequires(Object): + """This class defines the functionality for the 'requires' side of the 'ingress' relation. + + Hook events observed: + - relation-changed + + Attrs: + model: Juju model where the charm is deployed. + config_dict: Contains all the configuration options for Ingress. + """ + + def __init__(self, charm: CharmBase, config_dict: Dict) -> None: + """Init function for the IngressRequires class. + + Args: + charm: The charm that requires the ingress relation. + config_dict: Contains all the configuration options for Ingress. + """ + super().__init__(charm, INGRESS_RELATION_NAME) + + self.framework.observe( + charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed + ) + + # Set default values. + default_relation_fields = { + "service-namespace": self.model.name, + } + config_dict.update( + (key, value) + for key, value in default_relation_fields.items() + if key not in config_dict or not config_dict[key] + ) + + self.config_dict = self._convert_to_relation_interface(config_dict) + + @staticmethod + def _convert_to_relation_interface(config_dict: Dict) -> Dict: + """Create a new relation dict that conforms with charm-relation-interfaces. + + Args: + config_dict: Ingress configuration that doesn't conform with charm-relation-interfaces. + + Returns: + The Ingress configuration conforming with charm-relation-interfaces. + """ + config_dict = copy.copy(config_dict) + config_dict.update( + (key, config_dict[old_key]) + for old_key, key in RELATION_INTERFACES_MAPPINGS.items() + if old_key in config_dict and config_dict[old_key] + ) + return config_dict + + def _config_dict_errors(self, config_dict: Dict, update_only: bool = False) -> bool: + """Check our config dict for errors. + + Args: + config_dict: Contains all the configuration options for Ingress. + update_only: If the charm needs to update only existing keys. + + Returns: + If we need to update the config dict or not. + """ + blocked_message = "Error in ingress relation, check `juju debug-log`" + unknown = [ + config_key + for config_key in config_dict + if config_key + not in REQUIRED_INGRESS_RELATION_FIELDS + | OPTIONAL_INGRESS_RELATION_FIELDS + | RELATION_INTERFACES_MAPPINGS_VALUES + ] + if unknown: + LOGGER.error( + "Ingress relation error, unknown key(s) in config dictionary found: %s", + ", ".join(unknown), + ) + self.model.unit.status = BlockedStatus(blocked_message) + return True + if not update_only: + missing = tuple( + config_key + for config_key in REQUIRED_INGRESS_RELATION_FIELDS + if config_key not in self.config_dict + ) + if missing: + LOGGER.error( + "Ingress relation error, missing required key(s) in config dictionary: %s", + ", ".join(sorted(missing)), + ) + self.model.unit.status = BlockedStatus(blocked_message) + return True + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle the relation-changed event. + + Args: + event: Event triggering the relation-changed hook for the relation. + """ + # `self.unit` isn't available here, so use `self.model.unit`. + if self.model.unit.is_leader(): + if self._config_dict_errors(config_dict=self.config_dict): + return + event.relation.data[self.model.app].update( + (key, str(self.config_dict[key])) for key in self.config_dict + ) + + def update_config(self, config_dict: Dict) -> None: + """Allow for updates to relation. + + Args: + config_dict: Contains all the configuration options for Ingress. + + Attrs: + config_dict: Contains all the configuration options for Ingress. + """ + if self.model.unit.is_leader(): + self.config_dict = self._convert_to_relation_interface(config_dict) + if self._config_dict_errors(self.config_dict, update_only=True): + return + relation = self.model.get_relation(INGRESS_RELATION_NAME) + if relation: + for key in self.config_dict: + relation.data[self.model.app][key] = str(self.config_dict[key]) + + +class IngressBaseProvides(Object): + """Parent class for IngressProvides and IngressProxyProvides. + + Attrs: + model: Juju model where the charm is deployed. + """ + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + """Init function for the IngressProxyProvides class. + + Args: + charm: The charm that provides the ingress-proxy relation. + relation_name: The name of the relation. + """ + super().__init__(charm, relation_name) + self.charm = charm + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle a change to the ingress/ingress-proxy relation. + + Confirm we have the fields we expect to receive. + + Args: + event: Event triggering the relation-changed hook for the relation. + """ + # `self.unit` isn't available here, so use `self.model.unit`. + if not self.model.unit.is_leader(): + return + + relation_name = event.relation.name + + assert event.app is not None # nosec + if not event.relation.data[event.app]: + LOGGER.info( + "%s hasn't finished configuring, waiting until relation is changed again.", + relation_name, + ) + return + + ingress_data = { + field: event.relation.data[event.app].get(field) + for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS + } + + missing_fields = sorted( + field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None + ) + + if missing_fields: + LOGGER.warning( + "Missing required data fields for %s relation: %s", + relation_name, + ", ".join(missing_fields), + ) + self.model.unit.status = BlockedStatus( + f"Missing fields for {relation_name}: {', '.join(missing_fields)}" + ) + + if relation_name == INGRESS_RELATION_NAME: + # Conform to charm-relation-interfaces. + if "name" in ingress_data and "port" in ingress_data: + name = ingress_data["name"] + port = ingress_data["port"] + else: + name = ingress_data["service-name"] + port = ingress_data["service-port"] + event.relation.data[self.model.app]["url"] = f"http://{name}:{port}/" + + # Create an event that our charm can use to decide it's okay to + # configure the ingress. + self.charm.on.ingress_available.emit() + elif relation_name == INGRESS_PROXY_RELATION_NAME: + self.charm.on.ingress_proxy_available.emit() + + +class IngressProvides(IngressBaseProvides): + """Class containing the functionality for the 'provides' side of the 'ingress' relation. + + Hook events observed: + - relation-changed + """ + + def __init__(self, charm: CharmBase) -> None: + """Init function for the IngressProvides class. + + Args: + charm: The charm that provides the ingress relation. + """ + super().__init__(charm, INGRESS_RELATION_NAME) + # Observe the relation-changed hook event and bind + # self.on_relation_changed() to handle the event. + self.framework.observe( + charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed + ) + self.framework.observe( + charm.on[INGRESS_RELATION_NAME].relation_broken, self._on_relation_broken + ) + + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handle a relation-broken event in the ingress relation. + + Args: + event: Event triggering the relation-broken hook for the relation. + """ + if not self.model.unit.is_leader(): + return + + # Create an event that our charm can use to remove the ingress resource. + self.charm.on.ingress_broken.emit(event.relation) + + +class IngressProxyProvides(IngressBaseProvides): + """Class containing the functionality for the 'provides' side of the 'ingress-proxy' relation. + + Hook events observed: + - relation-changed + """ + + def __init__(self, charm: CharmBase) -> None: + """Init function for the IngressProxyProvides class. + + Args: + charm: The charm that provides the ingress-proxy relation. + """ + super().__init__(charm, INGRESS_PROXY_RELATION_NAME) + # Observe the relation-changed hook event and bind + # self.on_relation_changed() to handle the event. + self.framework.observe( + charm.on[INGRESS_PROXY_RELATION_NAME].relation_changed, self._on_relation_changed + ) diff --git a/ops-sunbeam/unit_tests/lib/charms/ovn_central_k8s/v0/ovsdb.py b/ops-sunbeam/tests/lib/charms/ovn_central_k8s/v0/ovsdb.py similarity index 100% rename from ops-sunbeam/unit_tests/lib/charms/ovn_central_k8s/v0/ovsdb.py rename to ops-sunbeam/tests/lib/charms/ovn_central_k8s/v0/ovsdb.py diff --git a/ops-sunbeam/unit_tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py b/ops-sunbeam/tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py similarity index 100% rename from ops-sunbeam/unit_tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py rename to ops-sunbeam/tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py diff --git a/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v1/ingress.py b/ops-sunbeam/tests/lib/charms/traefik_k8s/v1/ingress.py similarity index 85% rename from ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v1/ingress.py rename to ops-sunbeam/tests/lib/charms/traefik_k8s/v1/ingress.py index e1769e8c..e393fb52 100644 --- a/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v1/ingress.py +++ b/ops-sunbeam/tests/lib/charms/traefik_k8s/v1/ingress.py @@ -69,7 +69,7 @@ LIBAPI = 1 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 5 +LIBPATCH = 15 DEFAULT_RELATION_NAME = "ingress" RELATION_INTERFACE = "ingress" @@ -98,6 +98,7 @@ INGRESS_REQUIRES_APP_SCHEMA = { "host": {"type": "string"}, "port": {"type": "string"}, "strip-prefix": {"type": "string"}, + "redirect-https": {"type": "string"}, }, "required": ["model", "name", "host", "port"], } @@ -113,18 +114,25 @@ INGRESS_PROVIDES_APP_SCHEMA = { try: from typing import TypedDict except ImportError: - from typing_extensions import TypedDict # py35 compat + 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}, + { + "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}) +ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData}) # type: ignore def _validate_data(data, schema): @@ -148,7 +156,7 @@ 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) + super().__init__(charm, relation_name + "_V1") self.charm: CharmBase = charm self.relation_name = relation_name @@ -161,8 +169,8 @@ class _IngressPerAppBase(Object): observe(rel_events.relation_joined, self._handle_relation) observe(rel_events.relation_changed, self._handle_relation) observe(rel_events.relation_broken, self._handle_relation_broken) - observe(charm.on.leader_elected, self._handle_upgrade_or_leader) - observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) + 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): @@ -183,8 +191,8 @@ class _IngressPerAppBase(Object): class _IPAEvent(RelationEvent): - __args__ = () # type: Tuple[str, ...] - __optional_kwargs__ = {} # type: Dict[str, Any] + __args__: Tuple[str, ...] = () + __optional_kwargs__: Dict[str, Any] = {} @classmethod def __attrs__(cls): @@ -202,7 +210,7 @@ class _IPAEvent(RelationEvent): obj = kwargs.get(attr, default) setattr(self, attr, obj) - def snapshot(self) -> dict: + def snapshot(self): dct = super().snapshot() for attr in self.__attrs__(): obj = getattr(self, attr) @@ -217,7 +225,7 @@ class _IPAEvent(RelationEvent): return dct - def restore(self, snapshot: dict) -> None: + def restore(self, snapshot) -> None: super().restore(snapshot) for attr, obj in snapshot.items(): setattr(self, attr, obj) @@ -226,14 +234,15 @@ class _IPAEvent(RelationEvent): class IngressPerAppDataProvidedEvent(_IPAEvent): """Event representing that ingress data has been provided for an app.""" - __args__ = ("name", "model", "port", "host", "strip_prefix") + __args__ = ("name", "model", "port", "host", "strip_prefix", "redirect_https") if typing.TYPE_CHECKING: - name = None # type: str - model = None # type: str - port = None # type: int - host = None # type: str - strip_prefix = False # type: bool + 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): @@ -250,7 +259,7 @@ class IngressPerAppProviderEvents(ObjectEvents): class IngressPerAppProvider(_IngressPerAppBase): """Implementation of the provider of ingress.""" - on = IngressPerAppProviderEvents() + on = IngressPerAppProviderEvents() # type: ignore def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): """Constructor for IngressPerAppProvider. @@ -267,17 +276,18 @@ class IngressPerAppProvider(_IngressPerAppBase): # notify listeners. if self.is_ready(event.relation): data = self._get_requirer_data(event.relation) - self.on.data_provided.emit( + 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) + self.on.data_removed.emit(event.relation) # type: ignore def wipe_ingress_data(self, relation: Relation): """Clear ingress data from relation.""" @@ -293,33 +303,34 @@ class IngressPerAppProvider(_IngressPerAppBase): return del relation.data[self.app]["ingress"] - def _get_requirer_data(self, relation: Relation) -> RequirerData: + 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 all((relation.app, relation.app.name)): + 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 = {} # type: Dict[str, Union[int, str]] - for k in ("port", "host", "model", "name", "mode", "strip-prefix"): + 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)) - return remote_data + 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: + 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: Relation = None): + 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)) @@ -330,14 +341,14 @@ class IngressPerAppProvider(_IngressPerAppBase): log.warning("Requirer not ready; validation error encountered: %s" % str(e)) return False - def _provided_url(self, relation: Relation) -> ProviderIngressData: + def _provided_url(self, relation: Relation) -> ProviderIngressData: # type: ignore """Fetch and validate this app databag; return the ingress url.""" - if not all((relation.app, relation.app.name, self.unit.is_leader())): + 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 {} # noqa + return typing.cast(ProviderIngressData, {}) # noqa # fetch the provider's app databag raw_data = relation.data[self.app].get("ingress") @@ -374,6 +385,9 @@ class IngressPerAppProvider(_IngressPerAppBase): 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 @@ -384,7 +398,7 @@ class IngressPerAppReadyEvent(_IPAEvent): __args__ = ("url",) if typing.TYPE_CHECKING: - url = None # type: str + url: Optional[str] = None class IngressPerAppRevokedEvent(RelationEvent): @@ -401,8 +415,9 @@ class IngressPerAppRequirerEvents(ObjectEvents): class IngressPerAppRequirer(_IngressPerAppBase): """Implementation of the requirer of the ingress relation.""" - on = IngressPerAppRequirerEvents() - # used to prevent spur1ious urls to be sent out if the event we're currently + 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() @@ -411,9 +426,10 @@ class IngressPerAppRequirer(_IngressPerAppBase): charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME, *, - host: str = None, - port: int = None, + host: Optional[str] = None, + port: Optional[int] = None, strip_prefix: bool = False, + redirect_https: bool = False, ): """Constructor for IngressRequirer. @@ -429,6 +445,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): 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 @@ -437,8 +454,9 @@ class IngressPerAppRequirer(_IngressPerAppBase): 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) + 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. @@ -458,13 +476,13 @@ class IngressPerAppRequirer(_IngressPerAppBase): if isinstance(event, RelationBrokenEvent) else self._get_url_from_relation_data() ) - if self._stored.current_url != new_url: - self._stored.current_url = new_url - self.on.ready.emit(event.relation, new_url) + 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 - self.on.revoked.emit(event.relation) + 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.""" @@ -484,7 +502,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): host, port = self._auto_data self.provide_ingress_requirements(host=host, port=port) - def provide_ingress_requirements(self, *, host: str = None, port: int): + 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. @@ -513,6 +531,9 @@ class IngressPerAppRequirer(_IngressPerAppBase): 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) @@ -527,7 +548,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): Returns None if the URL isn't available yet. """ relation = self.relation - if not relation: + if not relation or not relation.app: return None # fetch the provider's app databag @@ -553,6 +574,6 @@ class IngressPerAppRequirer(_IngressPerAppBase): Returns None if the URL isn't available yet. """ - data = self._stored.current_url or None # type: ignore + 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/ops-sunbeam/tests/scenario_tests/__init__.py b/ops-sunbeam/tests/scenario_tests/__init__.py new file mode 100644 index 00000000..9e60916c --- /dev/null +++ b/ops-sunbeam/tests/scenario_tests/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2021 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 aso.""" +import ops.testing + +ops.testing.SIMULATE_CAN_CONNECT = True diff --git a/ops-sunbeam/tests/scenario_tests/scenario_utils.py b/ops-sunbeam/tests/scenario_tests/scenario_utils.py new file mode 100644 index 00000000..b2fedb71 --- /dev/null +++ b/ops-sunbeam/tests/scenario_tests/scenario_utils.py @@ -0,0 +1,142 @@ +#!/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. + +"""Utilities for writing sunbeam scenario tests.""" + +import functools +import itertools + +from scenario import ( + Relation, + Secret, +) + +# Data used to create Relation objects. If an incomplete relation is being +# created only the 'endpoint', 'interface' and 'remote_app_name' key are +# used. +default_relations = { + "amqp": { + "endpoint": "amqp", + "interface": "rabbitmq", + "remote_app_name": "rabbitmq", + "remote_app_data": {"password": "foo"}, + "remote_units_data": {0: {"ingress-address": "host1"}}, + }, + "identity-credentials": { + "endpoint": "identity-credentials", + "interface": "keystone-credentials", + "remote_app_name": "keystone", + "remote_app_data": { + "api-version": "3", + "auth-host": "keystone.local", + "auth-port": "12345", + "auth-protocol": "http", + "internal-host": "keystone.internal", + "internal-port": "5000", + "internal-protocol": "http", + "credentials": "foo", + "project-name": "user-project", + "project-id": "uproj-id", + "user-domain-name": "udomain-name", + "user-domain-id": "udomain-id", + "project-domain-name": "pdomain_-ame", + "project-domain-id": "pdomain-id", + "region": "region12", + "public-endpoint": "http://10.20.21.11:80/openstack-keystone", + "internal-endpoint": "http://10.153.2.45:80/openstack-keystone", + }, + }, +} + + +def relation_combinations( + metadata, one_missing=False, incomplete_relation=False +): + """Based on a charms metadata generate tuples of relations. + + :param metadata: Dict of charm metadata + :param one_missing: Bool if set then each unique relations tuple will be + missing one relation. + :param one_missing: Bool if set then each unique relations tuple will + include one relation that has missing relation + data + """ + _incomplete_relations = [] + _complete_relations = [] + _relation_pairs = [] + for rel_name in metadata.get("requires", {}): + rel = default_relations[rel_name] + complete_relation = Relation( + endpoint=rel["endpoint"], + interface=rel["interface"], + remote_app_name=rel["remote_app_name"], + local_unit_data=rel.get("local_unit_data", {}), + remote_app_data=rel.get("remote_app_data", {}), + remote_units_data=rel.get("remote_units_data", {}), + ) + relation_missing_data = Relation( + endpoint=rel["endpoint"], + interface=rel["interface"], + remote_app_name=rel["remote_app_name"], + ) + _incomplete_relations.append(relation_missing_data) + _complete_relations.append(complete_relation) + _relation_pairs.append([relation_missing_data, complete_relation]) + + if not (one_missing or incomplete_relation): + return [tuple(_complete_relations)] + if incomplete_relation: + relations = list(itertools.product(*_relation_pairs)) + relations.remove(tuple(_complete_relations)) + return relations + if one_missing: + event_count = range(len(_incomplete_relations)) + else: + event_count = range(len(_incomplete_relations) + 1) + combinations = [] + for i in event_count: + combinations.extend( + list(itertools.combinations(_incomplete_relations, i)) + ) + return combinations + + +missing_relation = functools.partial( + relation_combinations, one_missing=True, incomplete_relation=False +) +incomplete_relation = functools.partial( + relation_combinations, one_missing=False, incomplete_relation=True +) +complete_relation = functools.partial( + relation_combinations, one_missing=False, incomplete_relation=False +) + + +def get_keystone_secret_definition(relations): + """Create the keystone identity secret.""" + ident_rel_id = None + secret = None + for relation in relations: + if relation.remote_app_name == "keystone": + ident_rel_id = relation.relation_id + if ident_rel_id: + secret = Secret( + id="foo", + contents={0: {"username": "svcuser1", "password": "svcpass1"}}, + owner="keystone", # or 'app' + remote_grants={ident_rel_id: {"my-service/0"}}, + ) + return secret diff --git a/ops-sunbeam/tests/scenario_tests/test_fixtures.py b/ops-sunbeam/tests/scenario_tests/test_fixtures.py new file mode 100644 index 00000000..1248e5d6 --- /dev/null +++ b/ops-sunbeam/tests/scenario_tests/test_fixtures.py @@ -0,0 +1,192 @@ +#!/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. + +"""Charm definitions for scenatio tests.""" + +import ops_sunbeam.charm as sunbeam_charm +import ops_sunbeam.container_handlers as sunbeam_chandlers +import ops_sunbeam.core as sunbeam_core + + +class MyCharm(sunbeam_charm.OSBaseOperatorCharm): + """Test charm for testing OSBaseOperatorCharm.""" + + service_name = "my-service" + + +MyCharm_Metadata = { + "name": "my-service", + "version": "3", + "bases": {"name": "ubuntu", "channel": "20.04/stable"}, + "tags": ["openstack", "identity", "misc"], + "subordinate": False, +} + + +class MyCharmMulti(sunbeam_charm.OSBaseOperatorCharm): + """Test charm for testing OSBaseOperatorCharm.""" + + # mandatory_relations = {"amqp", "database", "identity-credentials"} + mandatory_relations = {"amqp", "identity-credentials"} + service_name = "my-service" + + +MyCharmMulti_Metadata = { + "name": "my-service", + "version": "3", + "bases": {"name": "ubuntu", "channel": "20.04/stable"}, + "tags": ["openstack", "identity", "misc"], + "subordinate": False, + "requires": { + # "database": {"interface": "mysql_client", "limit": 1}, + "amqp": {"interface": "rabbitmq"}, + "identity-credentials": { + "interface": "keystone-credentials", + "limit": 1, + }, + }, +} + + +class NovaSchedulerPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): + """Pebble handler for Nova scheduler.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.enable_service_check = True + + def get_layer(self) -> dict: + """Nova Scheduler service layer. + + :returns: pebble layer configuration for scheduler service + :rtype: dict + """ + return { + "summary": "nova scheduler layer", + "description": "pebble configuration for nova services", + "services": { + "nova-scheduler": { + "override": "replace", + "summary": "Nova Scheduler", + "command": "nova-scheduler", + "startup": "enabled", + "user": "nova", + "group": "nova", + } + }, + } + + +class NovaConductorPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): + """Pebble handler for Nova Conductor container.""" + + def get_layer(self): + """Nova Conductor service. + + :returns: pebble service layer configuration for conductor service + :rtype: dict + """ + return { + "summary": "nova conductor layer", + "description": "pebble configuration for nova services", + "services": { + "nova-conductor": { + "override": "replace", + "summary": "Nova Conductor", + "command": "nova-conductor", + "startup": "enabled", + "user": "nova", + "group": "nova", + } + }, + } + + +class MyCharmK8S(sunbeam_charm.OSBaseOperatorCharmK8S): + """Test charm for testing OSBaseOperatorCharm.""" + + # mandatory_relations = {"amqp", "database", "identity-credentials"} + mandatory_relations = {"amqp", "identity-credentials"} + service_name = "my-service" + + def get_pebble_handlers(self): + """Pebble handlers for the operator.""" + return [ + NovaSchedulerPebbleHandler( + self, + "container1", + "container1-svc", + self.container_configs, + "/tmp", + self.configure_charm, + ), + NovaConductorPebbleHandler( + self, + "container2", + "container2-svc", + self.container_configs, + "/tmp", + self.configure_charm, + ), + ] + + +MyCharmK8S_Metadata = { + "name": "my-service", + "version": "3", + "bases": {"name": "ubuntu", "channel": "20.04/stable"}, + "tags": ["openstack", "identity", "misc"], + "subordinate": False, + "containers": { + "container1": {"resource": "container1-image"}, + "container2": {"resource": "container2-image"}, + }, + "requires": { + # "database": {"interface": "mysql_client", "limit": 1}, + "amqp": {"interface": "rabbitmq"}, + "identity-credentials": { + "interface": "keystone-credentials", + "limit": 1, + }, + }, +} + + +class MyCharmK8SAPI(sunbeam_charm.OSBaseOperatorCharmK8S): + """Test charm for testing OSBaseOperatorCharm.""" + + # mandatory_relations = {"amqp", "database", "identity-credentials"} + mandatory_relations = {"amqp", "identity-credentials"} + service_name = "my-service" + + +MyCharmK8SAPI_Metadata = { + "name": "my-service", + "version": "3", + "bases": {"name": "ubuntu", "channel": "20.04/stable"}, + "tags": ["openstack", "identity", "misc"], + "subordinate": False, + "containers": { + "my-service": {"resource": "container1-image"}, + }, + "requires": { + # "database": {"interface": "mysql_client", "limit": 1}, + "amqp": {"interface": "rabbitmq"}, + "identity-credentials": { + "interface": "keystone-credentials", + }, + }, +} diff --git a/ops-sunbeam/tests/scenario_tests/test_scenario.py b/ops-sunbeam/tests/scenario_tests/test_scenario.py new file mode 100644 index 00000000..21126a03 --- /dev/null +++ b/ops-sunbeam/tests/scenario_tests/test_scenario.py @@ -0,0 +1,383 @@ +#!/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. + +"""Test charms for unit tests.""" +from . import test_fixtures +from . import scenario_utils as utils +import re +import sys + +sys.path.append("tests/lib") # noqa +sys.path.append("src") # noqa + +import pytest +from scenario import ( + State, + Context, + Container, + Mount, +) +from ops.model import ( + ActiveStatus, + MaintenanceStatus, +) + + +class TestOSBaseOperatorCharmScenarios: + @pytest.mark.parametrize("leader", (True, False)) + def test_no_relations(self, leader): + """Check charm with no relations becomes active.""" + state = State(leader=leader, config={}, containers=[]) + ctxt = Context( + charm_type=test_fixtures.MyCharm, + meta=test_fixtures.MyCharm_Metadata, + ) + out = ctxt.run("install", state) + assert out.unit_status == MaintenanceStatus( + "(bootstrap) Service not bootstrapped" + ) + out = ctxt.run("config-changed", state) + assert out.unit_status == ActiveStatus("") + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.missing_relation(test_fixtures.MyCharmMulti_Metadata), + ) + def test_relation_missing(self, relations, leader): + """Check charm with a missing relation is blocked.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmMulti, + meta=test_fixtures.MyCharmMulti_Metadata, + ) + state = State( + leader=True, + config={}, + containers=[], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status.name == "blocked" + assert re.match(r".*integration missing", out.unit_status.message) + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.incomplete_relation(test_fixtures.MyCharmMulti_Metadata), + ) + def test_relation_incomplete(self, relations, leader): + """Check charm with an incomplete relation is waiting.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmMulti, + meta=test_fixtures.MyCharmMulti_Metadata, + ) + state = State( + leader=True, + config={}, + containers=[], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status.name == "waiting" + assert re.match( + r".*Not all relations are ready", out.unit_status.message + ) + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.complete_relation(test_fixtures.MyCharmMulti_Metadata), + ) + def test_relations_complete(self, relations, leader): + """Check charm with complete relations is active.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmMulti, + meta=test_fixtures.MyCharmMulti_Metadata, + ) + state = State( + leader=True, + config={}, + containers=[], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status == ActiveStatus("") + + +class TestOSBaseOperatorCharmK8SScenarios: + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", utils.missing_relation(test_fixtures.MyCharmK8S_Metadata) + ) + def test_relation_missing(self, tmp_path, relations, leader): + """Check k8s charm with a missing relation is blocked.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8S, + meta=test_fixtures.MyCharmK8S_Metadata, + ) + p1 = tmp_path / "c1" + p2 = tmp_path / "c2" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="container1", + can_connect=True, + mounts={"local": Mount("/etc", p1)}, + ), + Container( + name="container2", + can_connect=True, + mounts={"local": Mount("/etc", p2)}, + ), + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert re.match(r".*integration missing", out.unit_status.message) + assert out.unit_status.name == "blocked" + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.incomplete_relation(test_fixtures.MyCharmK8S_Metadata), + ) + def test_relation_incomplete(self, tmp_path, relations, leader): + """Check k8s charm with an incomplete relation is waiting.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8S, + meta=test_fixtures.MyCharmK8S_Metadata, + ) + p1 = tmp_path / "c1" + p2 = tmp_path / "c2" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="container1", + can_connect=True, + mounts={"local": Mount("/etc", p1)}, + ), + Container( + name="container2", + can_connect=True, + mounts={"local": Mount("/etc", p2)}, + ), + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status.name == "waiting" + assert re.match( + r".*Not all relations are ready", out.unit_status.message + ) + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", utils.complete_relation(test_fixtures.MyCharmK8S_Metadata) + ) + def test_relation_container_not_ready(self, tmp_path, relations, leader): + """Check k8s charm with container is cannot connect to it waiting .""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8S, + meta=test_fixtures.MyCharmK8S_Metadata, + ) + p1 = tmp_path / "c1" + p2 = tmp_path / "c2" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="container1", + can_connect=False, + mounts={"local": Mount("/etc", p1)}, + ), + Container( + name="container2", + can_connect=True, + mounts={"local": Mount("/etc", p2)}, + ), + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status.name == "waiting" + assert re.match( + r".*Payload container not ready", out.unit_status.message + ) + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", utils.complete_relation(test_fixtures.MyCharmK8S_Metadata) + ) + def test_relation_all_complete(self, tmp_path, relations, leader): + """Check k8s charm with complete rels & ready containers is active.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8S, + meta=test_fixtures.MyCharmK8S_Metadata, + ) + p1 = tmp_path / "c1" + p2 = tmp_path / "c2" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="container1", + can_connect=True, + mounts={"local": Mount("/etc", p1)}, + ), + Container( + name="container2", + can_connect=True, + mounts={"local": Mount("/etc", p2)}, + ), + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status == ActiveStatus("") + + +class TestOSBaseOperatorCharmK8SAPIScenarios: + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.missing_relation(test_fixtures.MyCharmK8SAPI_Metadata), + ) + def test_relation_missing(self, tmp_path, relations, leader): + """Check k8s API charm with a missing relation is blocked.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8SAPI, + meta=test_fixtures.MyCharmK8SAPI_Metadata, + ) + p1 = tmp_path / "c1" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="my-service", + can_connect=True, + mounts={"local": Mount("/etc", p1)}, + ) + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert re.match(r".*integration missing", out.unit_status.message) + assert out.unit_status.name == "blocked" + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.incomplete_relation(test_fixtures.MyCharmK8SAPI_Metadata), + ) + def test_relation_incomplete(self, tmp_path, relations, leader): + """Check k8s API charm with an incomplete relation is waiting.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8SAPI, + meta=test_fixtures.MyCharmK8SAPI_Metadata, + ) + p1 = tmp_path / "c1" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="my-service", + can_connect=True, + mounts={"local": Mount("/etc", p1)}, + ) + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status.name == "waiting" + assert re.match( + r".*Not all relations are ready", out.unit_status.message + ) + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.complete_relation(test_fixtures.MyCharmK8SAPI_Metadata), + ) + def test_relation_container_not_ready(self, tmp_path, relations, leader): + """Check k8s API charm with stopped container is waiting.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8SAPI, + meta=test_fixtures.MyCharmK8SAPI_Metadata, + ) + p1 = tmp_path / "c1" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="my-service", + can_connect=False, + mounts={"local": Mount("/etc", p1)}, + ) + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status.name == "waiting" + assert re.match( + r".*Payload container not ready", out.unit_status.message + ) + + @pytest.mark.parametrize("leader", (True, False)) + @pytest.mark.parametrize( + "relations", + utils.complete_relation(test_fixtures.MyCharmK8SAPI_Metadata), + ) + def test_relation_all_complete(self, tmp_path, relations, leader): + """Check k8s API charm all rels and containers are ready.""" + ctxt = Context( + charm_type=test_fixtures.MyCharmK8SAPI, + meta=test_fixtures.MyCharmK8SAPI_Metadata, + ) + p1 = tmp_path / "c1" + state = State( + leader=True, + config={}, + containers=[ + Container( + name="my-service", + can_connect=True, + mounts={"local": Mount("/etc", p1)}, + ) + ], + relations=list(relations), + secrets=[utils.get_keystone_secret_definition(relations)], + ) + out = ctxt.run("config-changed", state) + assert out.unit_status == ActiveStatus("") diff --git a/ops-sunbeam/tests/unit_tests/__init__.py b/ops-sunbeam/tests/unit_tests/__init__.py new file mode 100644 index 00000000..9e60916c --- /dev/null +++ b/ops-sunbeam/tests/unit_tests/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2021 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 aso.""" +import ops.testing + +ops.testing.SIMULATE_CAN_CONNECT = True diff --git a/ops-sunbeam/unit_tests/test_charms.py b/ops-sunbeam/tests/unit_tests/test_charms.py similarity index 99% rename from ops-sunbeam/unit_tests/test_charms.py rename to ops-sunbeam/tests/unit_tests/test_charms.py index fc2202df..fa15455b 100644 --- a/ops-sunbeam/unit_tests/test_charms.py +++ b/ops-sunbeam/tests/unit_tests/test_charms.py @@ -30,7 +30,7 @@ from typing import ( List, ) -sys.path.append("unit_tests/lib") # noqa +sys.path.append("tests/unit_tests/lib") # noqa sys.path.append("src") # noqa import ops_sunbeam.charm as sunbeam_charm diff --git a/ops-sunbeam/unit_tests/test_compound_status.py b/ops-sunbeam/tests/unit_tests/test_compound_status.py similarity index 100% rename from ops-sunbeam/unit_tests/test_compound_status.py rename to ops-sunbeam/tests/unit_tests/test_compound_status.py diff --git a/ops-sunbeam/unit_tests/test_core.py b/ops-sunbeam/tests/unit_tests/test_core.py similarity index 99% rename from ops-sunbeam/unit_tests/test_core.py rename to ops-sunbeam/tests/unit_tests/test_core.py index 04d7283c..91266b69 100644 --- a/ops-sunbeam/unit_tests/test_core.py +++ b/ops-sunbeam/tests/unit_tests/test_core.py @@ -19,7 +19,7 @@ import sys import mock -sys.path.append("lib") # noqa +sys.path.append("tests/lib") # noqa sys.path.append("src") # noqa import ops.model diff --git a/ops-sunbeam/unit_tests/test_job_ctrl.py b/ops-sunbeam/tests/unit_tests/test_job_ctrl.py similarity index 100% rename from ops-sunbeam/unit_tests/test_job_ctrl.py rename to ops-sunbeam/tests/unit_tests/test_job_ctrl.py diff --git a/ops-sunbeam/unit_tests/test_templating.py b/ops-sunbeam/tests/unit_tests/test_templating.py similarity index 100% rename from ops-sunbeam/unit_tests/test_templating.py rename to ops-sunbeam/tests/unit_tests/test_templating.py diff --git a/ops-sunbeam/tox.ini b/ops-sunbeam/tox.ini index 034a8e65..fea030ad 100644 --- a/ops-sunbeam/tox.ini +++ b/ops-sunbeam/tox.ini @@ -10,8 +10,9 @@ requires = virtualenv < 20.0 [vars] src_path = {toxinidir}/ops_sunbeam -tst_path = {toxinidir}/unit_tests/ -tst_lib_path = {toxinidir}/unit_tests/lib/ +tst_path = {toxinidir}/tests/unit_tests/ +scenario_tst_path = {toxinidir}/tests/scenario_tests/ +tst_lib_path = {toxinidir}/tests/lib/ pyproject_toml = {toxinidir}/pyproject.toml cookie_cutter_path = {toxinidir}/shared_code/sunbeam_charm/\{\{cookiecutter.service_name\}\} all_path = {[vars]src_path} {[vars]tst_path} @@ -20,7 +21,9 @@ all_path = {[vars]src_path} {[vars]tst_path} basepython = python3 install_command = pip install {opts} {packages} -commands = stestr run --slowest {posargs} +commands = + stestr run --slowest {posargs} + pytest -v --tb native {[vars]scenario_tst_path} --log-cli-level=INFO allowlist_externals = git charmcraft @@ -103,6 +106,14 @@ commands = coverage xml -o cover/coverage.xml coverage report +[testenv:scenario] +description = Scenario tests +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + pytest -v --tb native {[vars]scenario_tst_path} --log-cli-level=INFO + [coverage:run] branch = True concurrency = multiprocessing diff --git a/ops-sunbeam/unit_tests/lib/charms/nginx_ingress_integrator/v0/ingress.py b/ops-sunbeam/unit_tests/lib/charms/nginx_ingress_integrator/v0/ingress.py deleted file mode 100644 index c3fac4ca..00000000 --- a/ops-sunbeam/unit_tests/lib/charms/nginx_ingress_integrator/v0/ingress.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Library for the ingress relation. - -This library contains the Requires and Provides classes for handling -the ingress interface. - -Import `IngressRequires` in your charm, with two required options: - - "self" (the charm itself) - - config_dict - -`config_dict` accepts the following keys: - - service-hostname (required) - - service-name (required) - - service-port (required) - - additional-hostnames - - limit-rps - - limit-whitelist - - max-body-size - - owasp-modsecurity-crs - - path-routes - - retry-errors - - rewrite-enabled - - rewrite-target - - service-namespace - - session-cookie-max-age - - tls-secret-name - -See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions -of each, along with the required type. - -As an example, add the following to `src/charm.py`: -``` -from charms.nginx_ingress_integrator.v0.ingress import IngressRequires - -# In your charm's `__init__` method. -self.ingress = IngressRequires(self, {"service-hostname": self.config["external_hostname"], - "service-name": self.app.name, - "service-port": 80}) - -# In your charm's `config-changed` handler. -self.ingress.update_config({"service-hostname": self.config["external_hostname"]}) -``` -And then add the following to `metadata.yaml`: -``` -requires: - ingress: - interface: ingress -``` -You _must_ register the IngressRequires class as part of the `__init__` method -rather than, for instance, a config-changed event handler. This is because -doing so won't get the current relation changed event, because it wasn't -registered to handle the event (because it wasn't created in `__init__` when -the event was fired). -""" - -import logging - -from ops.charm import CharmEvents -from ops.framework import EventBase, EventSource, Object -from ops.model import BlockedStatus - -# The unique Charmhub library identifier, never change it -LIBID = "db0af4367506491c91663468fb5caa4c" - -# Increment this major API version when introducing breaking changes -LIBAPI = 0 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 10 - -logger = logging.getLogger(__name__) - -REQUIRED_INGRESS_RELATION_FIELDS = { - "service-hostname", - "service-name", - "service-port", -} - -OPTIONAL_INGRESS_RELATION_FIELDS = { - "additional-hostnames", - "limit-rps", - "limit-whitelist", - "max-body-size", - "owasp-modsecurity-crs", - "path-routes", - "retry-errors", - "rewrite-target", - "rewrite-enabled", - "service-namespace", - "session-cookie-max-age", - "tls-secret-name", -} - - -class IngressAvailableEvent(EventBase): - pass - - -class IngressBrokenEvent(EventBase): - pass - - -class IngressCharmEvents(CharmEvents): - """Custom charm events.""" - - ingress_available = EventSource(IngressAvailableEvent) - ingress_broken = EventSource(IngressBrokenEvent) - - -class IngressRequires(Object): - """This class defines the functionality for the 'requires' side of the 'ingress' relation. - - Hook events observed: - - relation-changed - """ - - def __init__(self, charm, config_dict): - super().__init__(charm, "ingress") - - self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed) - - self.config_dict = config_dict - - def _config_dict_errors(self, update_only=False): - """Check our config dict for errors.""" - blocked_message = "Error in ingress relation, check `juju debug-log`" - unknown = [ - x - for x in self.config_dict - if x not in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS - ] - if unknown: - logger.error( - "Ingress relation error, unknown key(s) in config dictionary found: %s", - ", ".join(unknown), - ) - self.model.unit.status = BlockedStatus(blocked_message) - return True - if not update_only: - missing = [x for x in REQUIRED_INGRESS_RELATION_FIELDS if x not in self.config_dict] - if missing: - logger.error( - "Ingress relation error, missing required key(s) in config dictionary: %s", - ", ".join(sorted(missing)), - ) - self.model.unit.status = BlockedStatus(blocked_message) - return True - return False - - def _on_relation_changed(self, event): - """Handle the relation-changed event.""" - # `self.unit` isn't available here, so use `self.model.unit`. - if self.model.unit.is_leader(): - if self._config_dict_errors(): - return - for key in self.config_dict: - event.relation.data[self.model.app][key] = str(self.config_dict[key]) - - def update_config(self, config_dict): - """Allow for updates to relation.""" - if self.model.unit.is_leader(): - self.config_dict = config_dict - if self._config_dict_errors(update_only=True): - return - relation = self.model.get_relation("ingress") - if relation: - for key in self.config_dict: - relation.data[self.model.app][key] = str(self.config_dict[key]) - - -class IngressProvides(Object): - """This class defines the functionality for the 'provides' side of the 'ingress' relation. - - Hook events observed: - - relation-changed - """ - - def __init__(self, charm): - super().__init__(charm, "ingress") - # Observe the relation-changed hook event and bind - # self.on_relation_changed() to handle the event. - self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed) - self.framework.observe(charm.on["ingress"].relation_broken, self._on_relation_broken) - self.charm = charm - - def _on_relation_changed(self, event): - """Handle a change to the ingress relation. - - Confirm we have the fields we expect to receive.""" - # `self.unit` isn't available here, so use `self.model.unit`. - if not self.model.unit.is_leader(): - return - - ingress_data = { - field: event.relation.data[event.app].get(field) - for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS - } - - missing_fields = sorted( - [ - field - for field in REQUIRED_INGRESS_RELATION_FIELDS - if ingress_data.get(field) is None - ] - ) - - if missing_fields: - logger.error( - "Missing required data fields for ingress relation: {}".format( - ", ".join(missing_fields) - ) - ) - self.model.unit.status = BlockedStatus( - "Missing fields for ingress: {}".format(", ".join(missing_fields)) - ) - - # Create an event that our charm can use to decide it's okay to - # configure the ingress. - self.charm.on.ingress_available.emit() - - def _on_relation_broken(self, _): - """Handle a relation-broken event in the ingress relation.""" - if not self.model.unit.is_leader(): - return - - # Create an event that our charm can use to remove the ingress resource. - self.charm.on.ingress_broken.emit()