diff --git a/ops-sunbeam/advanced_sunbeam_openstack/charm.py b/ops-sunbeam/advanced_sunbeam_openstack/charm.py index 4aa3d0552..9efab2a6b 100644 --- a/ops-sunbeam/advanced_sunbeam_openstack/charm.py +++ b/ops-sunbeam/advanced_sunbeam_openstack/charm.py @@ -110,7 +110,7 @@ class OSBaseOperatorCharm(ops.charm.CharmBase): "ingress", self.service_name, self.default_public_ingress_port, - self.configure_charm, + self._ingress_changed, ) handlers.append(self.ingress) if self.can_add_handler("peers", handlers): @@ -189,6 +189,20 @@ class OSBaseOperatorCharm(ops.charm.CharmBase): else: return None + def _ingress_changed(self, event: ops.framework.EventBase) -> None: + """Ingress changed callback. + + Invoked when the data on the ingress relation has changed. This will + update the relevant endpoints with the identity service, and then + call the configure_charm. + """ + logger.debug('Received an ingress_changed event') + if hasattr(self, 'id_svc') and hasattr(self, 'service_endpoints'): + logger.debug('Updating service endpoints after ingress relation ' + 'changed.') + self.id_svc.register_services(self.service_endpoints, self.region) + self.configure_charm(event) + def configure_charm(self, event: ops.framework.EventBase) -> None: """Catchall handler to configure charm services.""" if self.supports_peer_relation and not ( @@ -337,7 +351,7 @@ class OSBaseOperatorCharm(ops.charm.CharmBase): logger.warning('DB Sync Out: %s', line.strip()) logging.debug(f'Output from database sync: \n{out}') else: - logger.warn( + logger.warning( "Not DB sync ran. Charm does not specify self.db_sync_cmds") def _do_bootstrap(self) -> None: @@ -362,7 +376,6 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharm): self._state.set_default(db_ready=False) self.service_patcher = kube_svc_patch.KubernetesServicePatch( self, - service_type="LoadBalancer", ports=[(f"{self.app.name}", self.default_public_ingress_port)], ) @@ -375,7 +388,10 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharm): self, handlers: List[sunbeam_rhandlers.RelationHandler] = None ) -> List[sunbeam_rhandlers.RelationHandler]: """Relation handlers for the service.""" - handlers = handlers or [] + handlers = super().get_relation_handlers(handlers or []) + # Note: intentionally get the parent relations first, as the identity + # requires handler has an implicit dependency on the ingress relation. + # This should be fixed. if self.can_add_handler("identity-service", handlers): self.id_svc = sunbeam_rhandlers.IdentityServiceRequiresHandler( self, @@ -385,7 +401,6 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharm): self.model.config["region"], ) handlers.append(self.id_svc) - handlers = super().get_relation_handlers(handlers) return handlers def service_url(self, hostname: str) -> str: @@ -410,14 +425,27 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharm): if load_balancer_status: ingress_addresses = load_balancer_status.ingress if ingress_addresses: + logger.debug('Found ingress addresses on loadbalancer ' + 'status') ingress_address = ingress_addresses[0] - return ingress_address.hostname or ingress_address.ip + addr = ingress_address.hostname or ingress_address.ip + if addr: + logger.debug('Using ingress address from loadbalancer ' + f'as {addr}') + return ingress_address.hostname or ingress_address.ip - return None + hostname = self.model.get_binding( + 'identity-service' + ).network.ingress_address + return hostname @property def public_url(self) -> str: """Url for accessing the public endpoint for this service.""" + if hasattr(self, 'ingress') and self.ingress.url: + logger.debug('Ingress relation found, returning ingress.url of: ' + f'{self.ingress.url}') + return self.ingress.url return self.service_url(self.public_ingress_address) @property diff --git a/ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py b/ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py index ed6b86193..9f69c4f5b 100644 --- a/ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py +++ b/ops-sunbeam/advanced_sunbeam_openstack/relation_handlers.py @@ -18,6 +18,7 @@ import json import logging import cryptography.hazmat.primitives.serialization as serialization from typing import Callable, List, Tuple +from urllib.parse import urlparse import ops.charm import ops.framework @@ -118,36 +119,37 @@ class IngressHandler(RelationHandler): logger.debug("Setting up ingress event handler") # Lazy import to ensure this lib is only required if the charm # has this relation. - import charms.nginx_ingress_integrator.v0.ingress as ingress - interface = ingress.IngressRequires(self.charm, self.ingress_config) + import charms.traefik_k8s.v0.ingress as ingress + interface = ingress.IngressPerAppRequirer( + self.charm, + self.relation_name, + port=self.default_public_ingress_port, + ) return interface - @property - def ingress_config(self) -> dict: - """Ingress controller configuration dictionary.""" - # Most charms probably won't (or shouldn't) expose service-port - # but use it if its there. - port = self.model.config.get( - "service-port", self.default_public_ingress_port - ) - svc_hostname = self.model.config.get( - "os-public-hostname", self.service_name - ) - return { - "service-hostname": svc_hostname, - "service-name": self.charm.app.name, - "service-port": port, - } - @property def ready(self) -> bool: """Whether the handler is ready for use.""" # Nothing to wait for - return True + if self.interface.url: + return True + + return False + + @property + def url(self) -> str: + """Return the URL used by the remote ingress service.""" + if not self.ready: + return None + + return self.interface.url def context(self) -> dict: """Context containing ingress data.""" - return {} + parse_result = urlparse(self.url) + return { + 'ingress_path': parse_result.path, + } class DBHandler(RelationHandler): diff --git a/ops-sunbeam/fetch-libs.sh b/ops-sunbeam/fetch-libs.sh index 0a58745a5..ba311e4ad 100755 --- a/ops-sunbeam/fetch-libs.sh +++ b/ops-sunbeam/fetch-libs.sh @@ -7,5 +7,6 @@ charmcraft fetch-lib charms.sunbeam_keystone_operator.v0.identity_service charmcraft fetch-lib charms.sunbeam_rabbitmq_operator.v0.amqp charmcraft fetch-lib charms.sunbeam_ovn_central_operator.v0.ovsdb charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch +charmcraft fetch-lib charms.traefik_k8s.v0.ingress echo "Copying libs to to unit_test dir" rsync --recursive --delete lib/ unit_tests/lib/ diff --git a/ops-sunbeam/requirements.txt b/ops-sunbeam/requirements.txt index 0d2b206f6..41f15b4dd 100644 --- a/ops-sunbeam/requirements.txt +++ b/ops-sunbeam/requirements.txt @@ -6,4 +6,7 @@ python-keystoneclient git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack git+https://github.com/openstack/charm-ops-interface-ceph-client#egg=interface_ceph_client lightkube -lightkube-models \ No newline at end of file +lightkube-models + +# included for interfacing with traefik +serialized-data-interface>=0.4.0 diff --git a/ops-sunbeam/shared_code/aso_charm/{{cookiecutter.service_name}}/src/templates/wsgi-template.conf.j2 b/ops-sunbeam/shared_code/aso_charm/{{cookiecutter.service_name}}/src/templates/wsgi-template.conf.j2 index 981809a92..4bbfd8dac 100644 --- a/ops-sunbeam/shared_code/aso_charm/{{cookiecutter.service_name}}/src/templates/wsgi-template.conf.j2 +++ b/ops-sunbeam/shared_code/aso_charm/{{cookiecutter.service_name}}/src/templates/wsgi-template.conf.j2 @@ -1,8 +1,11 @@ Listen {{ wsgi_config.public_port }} - WSGIDaemonProcess glance processes=3 threads=1 user={{ wsgi_config.user }} group={{ wsgi_config.group }} \ + WSGIDaemonProcess {{ wsgi_config.group }} processes=3 threads=1 user={{ wsgi_config.user }} group={{ wsgi_config.group }} \ display-name=%{GROUP} - WSGIProcessGroup glance + WSGIProcessGroup {{ wsgi_config.group }} + {% if ingress.ingress_path -%} + WSGIScriptAlias {{ ingress.ingress_path }} {{ wsgi_config.wsgi_public_script }} + {% endif -%} WSGIScriptAlias / {{ wsgi_config.wsgi_public_script }} WSGIApplicationGroup %{GLOBAL} WSGIPassAuthorization On diff --git a/ops-sunbeam/test-requirements.txt b/ops-sunbeam/test-requirements.txt index 59f7ed844..9c7199d84 100644 --- a/ops-sunbeam/test-requirements.txt +++ b/ops-sunbeam/test-requirements.txt @@ -1,6 +1,5 @@ flake8-annotations flake8-docstrings -charm-tools>=2.4.4 coverage>=3.6 mock>=1.2 flake8 diff --git a/ops-sunbeam/tox.ini b/ops-sunbeam/tox.ini index 028fa55a5..4ee19f10d 100644 --- a/ops-sunbeam/tox.ini +++ b/ops-sunbeam/tox.ini @@ -47,16 +47,21 @@ basepython = python3 deps = -r{toxinidir}/cookie-requirements.txt commands = /bin/true -[testenv:py3.8] +[testenv:py38] basepython = python3.8 deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt -[testenv:py3.9] +[testenv:py39] basepython = python3.9 deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt +[testenv:py310] +basepython = python3.10 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + [testenv:py3] basepython = python3 deps = -r{toxinidir}/requirements.txt 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 index c8d2e0b19..c3fac4ca1 100644 --- 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 @@ -15,6 +15,7 @@ Import `IngressRequires` in your charm, with two required options: - limit-rps - limit-whitelist - max-body-size + - owasp-modsecurity-crs - path-routes - retry-errors - rewrite-enabled @@ -65,7 +66,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 = 9 +LIBPATCH = 10 logger = logging.getLogger(__name__) @@ -80,13 +81,14 @@ OPTIONAL_INGRESS_RELATION_FIELDS = { "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", - "path-routes", } @@ -94,10 +96,15 @@ 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): @@ -134,7 +141,7 @@ class IngressRequires(Object): if missing: logger.error( "Ingress relation error, missing required key(s) in config dictionary: %s", - ", ".join(missing), + ", ".join(sorted(missing)), ) self.model.unit.status = BlockedStatus(blocked_message) return True @@ -173,6 +180,7 @@ class IngressProvides(Object): # 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): @@ -209,3 +217,11 @@ class IngressProvides(Object): # 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() diff --git a/ops-sunbeam/unit_tests/lib/charms/sunbeam_ovn_central_operator/v0/ovsdb.py b/ops-sunbeam/unit_tests/lib/charms/sunbeam_ovn_central_operator/v0/ovsdb.py index f1ddf3bc2..26e9903e7 100644 --- a/ops-sunbeam/unit_tests/lib/charms/sunbeam_ovn_central_operator/v0/ovsdb.py +++ b/ops-sunbeam/unit_tests/lib/charms/sunbeam_ovn_central_operator/v0/ovsdb.py @@ -37,7 +37,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 # TODO: add your code here! Happy coding! @@ -122,8 +122,9 @@ class OVSDBCMSRequires(Object): """Retrieve value for key from all related units.""" values = [] relation = self.framework.model.get_relation(self.relation_name) - for unit in relation.units: - values.append(relation.data[unit].get(key)) + if relation: + for unit in relation.units: + values.append(relation.data[unit].get(key)) return values @@ -196,7 +197,8 @@ class OVSDBCMSProvides(Object): def set_unit_data(self, settings: typing.Dict[str, str]) -> None: """Publish settings on the peer unit data bag.""" - relation = self.framework.model.get_relation(self.relation_name) - for k, v in settings.items(): - relation.data[self.model.unit][k] = v + relations = self.framework.model.relations[self.relation_name] + for relation in relations: + for k, v in settings.items(): + relation.data[self.model.unit][k] = v diff --git a/ops-sunbeam/unit_tests/lib/charms/sunbeam_rabbitmq_operator/v0/amqp.py b/ops-sunbeam/unit_tests/lib/charms/sunbeam_rabbitmq_operator/v0/amqp.py index 4c1540dec..84b0b5a23 100644 --- a/ops-sunbeam/unit_tests/lib/charms/sunbeam_rabbitmq_operator/v0/amqp.py +++ b/ops-sunbeam/unit_tests/lib/charms/sunbeam_rabbitmq_operator/v0/amqp.py @@ -75,10 +75,9 @@ LIBAPI = 0 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 3 +LIBPATCH = 4 import logging -import requests from ops.framework import ( StoredState, @@ -160,7 +159,7 @@ class AMQPRequires(Object): def _on_amqp_relation_changed(self, event): """AMQP relation changed.""" - logging.debug("RabbitMQAMQPRequires on_changed") + logging.debug("RabbitMQAMQPRequires on_changed/departed") if self.password: self.on.ready.emit() @@ -237,10 +236,11 @@ class AMQPProvides(Object): on = AMQPClientEvents() _stored = StoredState() - def __init__(self, charm, relation_name): + 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, @@ -256,19 +256,24 @@ class AMQPProvides(Object): def _on_amqp_relation_joined(self, event): """Handle AMQP joined.""" - logging.debug("RabbitMQAMQPProvides on_joined") + logging.debug("RabbitMQAMQPProvides on_joined data={}" + .format(event.relation.data)) self.on.has_amqp_clients.emit() def _on_amqp_relation_changed(self, event): """Handle AMQP changed.""" - logging.debug("RabbitMQAMQPProvides on_changed") + logging.debug("RabbitMQAMQPProvides on_changed data={}" + .format(event.relation.data)) # 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.set_amqp_credentials( - event, self.username(event), self.vhost(event) - ) + self.callback(event, self.username(event), self.vhost(event)) + else: + logging.warning("Received AMQP 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 AMQP broken.""" @@ -282,33 +287,3 @@ class AMQPProvides(Object): def vhost(self, event): """Return the AMQP vhost from the client side of the relation.""" return event.relation.data[event.relation.app].get("vhost") - - def set_amqp_credentials(self, event, username, vhost): - """Set AMQP Credentials. - - :param event: The current event - :type EventsBase - :param username: The requested username - :type username: str - :param vhost: The requested vhost - :type vhost: str - :returns: None - :rtype: None - """ - # TODO: Can we move this into the charm code? - # TODO TLS Support. Existing interfaces set ssl_port and ssl_ca - logging.debug("Setting amqp connection information.") - try: - if not self.charm.does_vhost_exist(vhost): - self.charm.create_vhost(vhost) - password = self.charm.create_user(username) - self.charm.set_user_permissions(username, vhost) - event.relation.data[self.charm.app]["password"] = password - event.relation.data[self.charm.app][ - "hostname" - ] = self.charm.hostname - except requests.exceptions.ConnectionError as e: - logging.warning( - "Rabbitmq is not ready. Defering. Errno: {}".format(e.errno) - ) - event.defer() diff --git a/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v0/ingress.py b/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v0/ingress.py new file mode 100644 index 000000000..cd745cf72 --- /dev/null +++ b/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v0/ingress.py @@ -0,0 +1,377 @@ +# 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`. +**Note that you also need to add the `serialized_data_interface` dependency to your +charm's `requirements.txt`.** + +```shell +cd some-charm +charmcraft fetch-lib charms.traefik_k8s.v0.ingress +echo -e "serialized_data_interface\n" >> requirements.txt +``` + +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.v0.ingress import IngressPerAppRequirer + +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` changes or there is no longer + # an ingress URL available, that is, `self.ingress_per_unit` would + # return `None`. + self.framework.observe( + self.ingress.on.ingress_changed, self._handle_ingress + ) + # ... + + def _handle_ingress(self, event): + logger.info("This app's ingress URL: %s", self.ingress.url) +``` +""" + +import logging +from typing import Optional + +from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent, RelationRole +from ops.framework import EventSource, StoredState +from ops.model import Relation + +try: + from serialized_data_interface import EndpointWrapper + from serialized_data_interface.errors import RelationDataError, UnversionedRelation + from serialized_data_interface.events import EndpointWrapperEvents +except ImportError: + import os + + library_name = os.path.basename(__file__) + raise ModuleNotFoundError( + "To use the '{}' library, you must include " + "the '{}' package in your dependencies".format(library_name, "serialized_data_interface") + ) from None # Suppress original ImportError + +# The unique Charmhub library identifier, never change it +LIBID = "e6de2a5cd5b34422a204668f3b8f90d2" + +# 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 = 5 + +log = logging.getLogger(__name__) + +INGRESS_SCHEMA = { + "v1": { + "requires": { + "app": { + "type": "object", + "properties": { + "model": {"type": "string"}, + "name": {"type": "string"}, + "host": {"type": "string"}, + "port": {"type": "integer"}, + }, + "required": ["model", "name", "host", "port"], + }, + }, + "provides": { + "app": { + "type": "object", + "properties": { + "ingress": { + "type": "object", + "properties": { + "url": {"type": "string"}, + }, + } + }, + "required": ["ingress"], + }, + }, + } +} + + +class IngressPerAppRequestEvent(RelationEvent): + """Event representing an incoming request. + + This is equivalent to the "ready" event, but is more semantically meaningful. + """ + + +class IngressPerAppProviderEvents(EndpointWrapperEvents): + """Container for IUP events.""" + + request = EventSource(IngressPerAppRequestEvent) + + +class IngressPerAppProvider(EndpointWrapper): + """Implementation of the provider of ingress.""" + + ROLE = RelationRole.provides.name + INTERFACE = "ingress" + SCHEMA = INGRESS_SCHEMA + + on = IngressPerAppProviderEvents() + + def __init__(self, charm: CharmBase, endpoint: str = None): + """Constructor for IngressPerAppProvider. + + Args: + charm: The charm that is instantiating the instance. + endpoint: The name of the relation endpoint to bind to + (defaults to "ingress"). + """ + super().__init__(charm, endpoint) + self.framework.observe(self.on.ready, self._emit_request_event) + + def _emit_request_event(self, event): + self.on.request.emit(event.relation) + + def get_request(self, relation: Relation): + """Get the IngressPerAppRequest for the given Relation.""" + return IngressPerAppRequest(self, relation) + + def is_failed(self, relation: Relation = None): + """Checks whether the given relation, or any relation if not specified, has an error.""" + if relation is None: + return any(self.is_failed(relation) for relation in self.relations) + if super().is_failed(relation): + return True + try: + data = self.unwrap(relation) + except UnversionedRelation: + return False + + prev_fields = None + + other_app = relation.app + + new_fields = { + field: data[other_app][field] + for field in ("model", "port") + if field in data[other_app] + } + if prev_fields is None: + prev_fields = new_fields + if new_fields != prev_fields: + raise RelationDataMismatchError(relation, other_app) + return False + + @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" + } + } + ``` + """ + return { + ingress_relation.app.name: self.unwrap(ingress_relation)[self.charm.app].get( + "ingress", {} + ) + for ingress_relation in self.charm.model.relations[self.endpoint] + } + + +class IngressPerAppRequest: + """A request for per-application ingress.""" + + def __init__(self, provider: IngressPerAppProvider, relation: Relation): + """Construct an IngressRequest.""" + self._provider = provider + self._relation = relation + self._data = provider.unwrap(relation) + + @property + def model(self): + """The name of the model the request was made from.""" + return self._data[self.app].get("model") + + @property + def app(self): + """The remote application.""" + return self._relation.app + + @property + def app_name(self): + """The name of the remote app. + + Note: This is not the same as `self.app.name` when using CMR relations, + since `self.app.name` is replaced by a `remote-{UUID}` pattern. + """ + return self._relation.app.name + + @property + def host(self): + """The hostname to be used to route to the application.""" + return self._data[self.app].get("host") + + @property + def port(self): + """The port to be used to route to the application.""" + return self._data[self.app].get("port") + + def respond(self, url: str): + """Send URL back for the application. + + Note: only the leader can send URLs. + """ + ingress = self._data[self._provider.charm.app].setdefault("ingress", {}) + ingress["url"] = url + self._provider.wrap(self._relation, self._data) + + +class RelationDataMismatchError(RelationDataError): + """Data from different units do not match where they should.""" + + +class IngressPerAppConfigurationChangeEvent(RelationEvent): + """Event representing a change in the data sent by the ingress.""" + + +class IngressPerAppRequirerEvents(EndpointWrapperEvents): + """Container for IUP events.""" + + ingress_changed = EventSource(IngressPerAppConfigurationChangeEvent) + + +class IngressPerAppRequirer(EndpointWrapper): + """Implementation of the requirer of the ingress relation.""" + + on = IngressPerAppRequirerEvents() + _stored = StoredState() + + ROLE = RelationRole.requires.name + INTERFACE = "ingress" + SCHEMA = INGRESS_SCHEMA + LIMIT = 1 + + def __init__( + self, + charm: CharmBase, + endpoint: str = None, + *, + host: str = None, + port: int = None, + ): + """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. + endpoint: 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. + + Request Args: + port: the port of the service + """ + super().__init__(charm, endpoint) + + # Workaround for SDI not marking the EndpointWrapper as not + # ready upon a relation broken event + self.is_relation_broken = False + + self._stored.set_default(current_url=None) + + if port and charm.unit.is_leader(): + self.auto_data = self._complete_request(host or "", port) + + self.framework.observe( + self.charm.on[self.endpoint].relation_changed, self._emit_ingress_change_event + ) + self.framework.observe( + self.charm.on[self.endpoint].relation_broken, self._emit_ingress_change_event + ) + + def _emit_ingress_change_event(self, event): + if isinstance(event, RelationBrokenEvent): + self.is_relation_broken = True + + # Avoid spurious events, emit only when URL changes + new_url = self.url + if self._stored.current_url != new_url: + self._stored.current_url = new_url + self.on.ingress_changed.emit(self.relation) + + def _complete_request(self, host: Optional[str], port: int): + if not host: + # TODO Make host mandatory? + host = "{app_name}.{model_name}.svc.cluster.local".format( + app_name=self.app.name, + model_name=self.model.name, + ) + + return { + self.app: { + "model": self.model.name, + "name": self.charm.unit.name, + "host": host, + "port": port, + } + } + + def request(self, *, host: str = None, port: int): + """Request ingress to this application. + + Args: + host: Hostname to be used by the ingress provider to address the requirer; if + unspecified, the Kubernetes service address is used. + port: the port of the service (required) + """ + self.wrap(self.relation, self._complete_request(host, port)) + + @property + def relation(self): + """The established Relation instance, or None.""" + return self.relations[0] if self.relations else None + + @property + def url(self): + """The full ingress URL to reach the current unit. + + May return None if the URL isn't available yet. + """ + if self.is_relation_broken or not self.is_ready(): + return {} + data = self.unwrap(self.relation) + ingress = data[self.relation.app].get("ingress", {}) + return ingress.get("url") diff --git a/ops-sunbeam/unit_tests/test_charms.py b/ops-sunbeam/unit_tests/test_charms.py index eade4149d..eaea7d389 100644 --- a/ops-sunbeam/unit_tests/test_charms.py +++ b/ops-sunbeam/unit_tests/test_charms.py @@ -96,6 +96,7 @@ requires: limit: 1 ingress: interface: ingress + limit: 1 amqp: interface: rabbitmq identity-service: