diff --git a/README.md b/README.md index c9f2cb1f..69ef27f7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,20 @@ juju deploy kratos juju relate kratos postgresql-k8s ``` +### Ingress + +The Kratos Operator offers integration with the [traefik-k8s-operator](https://github.com/canonical/traefik-k8s-operator) for ingress. Kratos has two APIs which can be exposed through ingress, the public API and the admin API. + +If you have a traefik deployed and configured in your kratos model, to provide ingress to the admin API run: +```console +juju relate traefik-admin kratos:admin-ingress +``` + +To provide ingress to the public API run: +```console +juju relate traefik-public kratos:public-ingress +``` + ### Interacting with Kratos API Below are two examples of the API. Visit [Ory](https://www.ory.sh/docs/kratos/reference/api) to see full API specification. diff --git a/integration-requirements.txt b/integration-requirements.txt index aab361eb..3e608927 100644 --- a/integration-requirements.txt +++ b/integration-requirements.txt @@ -1,4 +1,5 @@ pytest juju pytest-operator==0.22.0 +requests -r requirements.txt diff --git a/lib/charms/traefik_k8s/v1/ingress.py b/lib/charms/traefik_k8s/v1/ingress.py new file mode 100644 index 00000000..69008a73 --- /dev/null +++ b/lib/charms/traefik_k8s/v1/ingress.py @@ -0,0 +1,564 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +r"""# Interface Library for ingress. + +This library wraps relation endpoints using the `ingress` interface +and provides a Python API for both requesting and providing per-application +ingress, with load-balancing occurring across all units. + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. + +```shell +cd some-charm +charmcraft fetch-lib charms.traefik_k8s.v1.ingress +``` + +In the `metadata.yaml` of the charm, add the following: + +```yaml +requires: + ingress: + interface: ingress + limit: 1 +``` + +Then, to initialise the library: + +```python +from charms.traefik_k8s.v1.ingress import (IngressPerAppRequirer, + IngressPerAppReadyEvent, IngressPerAppRevokedEvent) + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.ingress = IngressPerAppRequirer(self, port=80) + # The following event is triggered when the ingress URL to be used + # by this deployment of the `SomeCharm` is ready (or changes). + self.framework.observe( + self.ingress.on.ready, self._on_ingress_ready + ) + self.framework.observe( + self.ingress.on.revoked, self._on_ingress_revoked + ) + + def _on_ingress_ready(self, event: IngressPerAppReadyEvent): + logger.info("This app's ingress URL: %s", event.url) + + def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): + logger.info("This app no longer has ingress") +""" + +import logging +import socket +import typing +from typing import Any, Dict, Optional, Tuple, Union + +import yaml +from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent +from ops.framework import EventSource, Object, ObjectEvents, StoredState +from ops.model import ModelError, Relation + +# The unique Charmhub library identifier, never change it +LIBID = "e6de2a5cd5b34422a204668f3b8f90d2" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 7 + +DEFAULT_RELATION_NAME = "ingress" +RELATION_INTERFACE = "ingress" + +log = logging.getLogger(__name__) + +try: + import jsonschema + + DO_VALIDATION = True +except ModuleNotFoundError: + log.warning( + "The `ingress` library needs the `jsonschema` package to be able " + "to do runtime data validation; without it, it will still work but validation " + "will be disabled. \n" + "It is recommended to add `jsonschema` to the 'requirements.txt' of your charm, " + "which will enable this feature." + ) + DO_VALIDATION = False + +INGRESS_REQUIRES_APP_SCHEMA = { + "type": "object", + "properties": { + "model": {"type": "string"}, + "name": {"type": "string"}, + "host": {"type": "string"}, + "port": {"type": "string"}, + "strip-prefix": {"type": "string"}, + }, + "required": ["model", "name", "host", "port"], +} + +INGRESS_PROVIDES_APP_SCHEMA = { + "type": "object", + "properties": { + "ingress": {"type": "object", "properties": {"url": {"type": "string"}}}, + }, + "required": ["ingress"], +} + +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict # py35 compat + +# Model of the data a unit implementing the requirer will need to provide. +RequirerData = TypedDict( + "RequirerData", + {"model": str, "name": str, "host": str, "port": int, "strip-prefix": bool}, + total=False, +) +# Provider ingress data model. +ProviderIngressData = TypedDict("ProviderIngressData", {"url": str}) +# Provider application databag model. +ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData}) # type: ignore + + +def _validate_data(data, schema): + """Checks whether `data` matches `schema`. + + Will raise DataValidationError if the data is not valid, else return None. + """ + if not DO_VALIDATION: + return + try: + jsonschema.validate(instance=data, schema=schema) + except jsonschema.ValidationError as e: + raise DataValidationError(data, schema) from e + + +class DataValidationError(RuntimeError): + """Raised when data validation fails on IPU relation data.""" + + +class _IngressPerAppBase(Object): + """Base class for IngressPerUnit interface classes.""" + + def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): + super().__init__(charm, relation_name) + + self.charm: CharmBase = charm + self.relation_name = relation_name + self.app = self.charm.app + self.unit = self.charm.unit + + observe = self.framework.observe + rel_events = charm.on[relation_name] + observe(rel_events.relation_created, self._handle_relation) + observe(rel_events.relation_joined, self._handle_relation) + observe(rel_events.relation_changed, self._handle_relation) + observe(rel_events.relation_broken, self._handle_relation_broken) + observe(charm.on.leader_elected, self._handle_upgrade_or_leader) # type: ignore + observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore + + @property + def relations(self): + """The list of Relation instances associated with this endpoint.""" + return list(self.charm.model.relations[self.relation_name]) + + def _handle_relation(self, event): + """Subclasses should implement this method to handle a relation update.""" + pass + + def _handle_relation_broken(self, event): + """Subclasses should implement this method to handle a relation breaking.""" + pass + + def _handle_upgrade_or_leader(self, event): + """Subclasses should implement this method to handle upgrades or leadership change.""" + pass + + +class _IPAEvent(RelationEvent): + __args__ = () # type: Tuple[str, ...] + __optional_kwargs__ = {} # type: Dict[str, Any] + + @classmethod + def __attrs__(cls): + return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) + + def __init__(self, handle, relation, *args, **kwargs): + super().__init__(handle, relation) + + if not len(self.__args__) == len(args): + raise TypeError("expected {} args, got {}".format(len(self.__args__), len(args))) + + for attr, obj in zip(self.__args__, args): + setattr(self, attr, obj) + for attr, default in self.__optional_kwargs__.items(): + obj = kwargs.get(attr, default) + setattr(self, attr, obj) + + def snapshot(self) -> dict: + dct = super().snapshot() + for attr in self.__attrs__(): + obj = getattr(self, attr) + try: + dct[attr] = obj + except ValueError as e: + raise ValueError( + "cannot automagically serialize {}: " + "override this method and do it " + "manually.".format(obj) + ) from e + + return dct + + def restore(self, snapshot: dict) -> None: + super().restore(snapshot) + for attr, obj in snapshot.items(): + setattr(self, attr, obj) + + +class IngressPerAppDataProvidedEvent(_IPAEvent): + """Event representing that ingress data has been provided for an app.""" + + __args__ = ("name", "model", "port", "host", "strip_prefix") + + if typing.TYPE_CHECKING: + name = None # type: Optional[str] + model = None # type: Optional[str] + port = None # type: Optional[str] + host = None # type: Optional[str] + strip_prefix = False # type: bool + + +class IngressPerAppDataRemovedEvent(RelationEvent): + """Event representing that ingress data has been removed for an app.""" + + +class IngressPerAppProviderEvents(ObjectEvents): + """Container for IPA Provider events.""" + + data_provided = EventSource(IngressPerAppDataProvidedEvent) + data_removed = EventSource(IngressPerAppDataRemovedEvent) + + +class IngressPerAppProvider(_IngressPerAppBase): + """Implementation of the provider of ingress.""" + + on = IngressPerAppProviderEvents() + + def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): + """Constructor for IngressPerAppProvider. + + Args: + charm: The charm that is instantiating the instance. + relation_name: The name of the relation endpoint to bind to + (defaults to "ingress"). + """ + super().__init__(charm, relation_name) + + def _handle_relation(self, event): + # created, joined or changed: if remote side has sent the required data: + # notify listeners. + if self.is_ready(event.relation): + data = self._get_requirer_data(event.relation) + self.on.data_provided.emit( # type: ignore + event.relation, + data["name"], + data["model"], + data["port"], + data["host"], + data.get("strip-prefix", False), + ) + + def _handle_relation_broken(self, event): + self.on.data_removed.emit(event.relation) # type: ignore + + def wipe_ingress_data(self, relation: Relation): + """Clear ingress data from relation.""" + assert self.unit.is_leader(), "only leaders can do this" + try: + relation.data + except ModelError as e: + log.warning( + "error {} accessing relation data for {!r}. " + "Probably a ghost of a dead relation is still " + "lingering around.".format(e, relation.name) + ) + return + del relation.data[self.app]["ingress"] + + def _get_requirer_data(self, relation: Relation) -> RequirerData: # type: ignore + """Fetch and validate the requirer's app databag. + + For convenience, we convert 'port' to integer. + """ + assert relation.app, "no app in relation (shouldn't happen)" # for type checker + if not all((relation.app, relation.app.name)): + # Handle edge case where remote app name can be missing, e.g., + # relation_broken events. + # FIXME https://github.com/canonical/traefik-k8s-operator/issues/34 + return {} + + databag = relation.data[relation.app] + remote_data = {} # type: Dict[str, Union[int, str]] + for k in ("port", "host", "model", "name", "mode", "strip-prefix"): + v = databag.get(k) + if v is not None: + remote_data[k] = v + _validate_data(remote_data, INGRESS_REQUIRES_APP_SCHEMA) + remote_data["port"] = int(remote_data["port"]) + remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", False)) + return remote_data + + def get_data(self, relation: Relation) -> RequirerData: # type: ignore + """Fetch the remote app's databag, i.e. the requirer data.""" + return self._get_requirer_data(relation) + + def is_ready(self, relation: Optional[Relation] = None): + """The Provider is ready if the requirer has sent valid data.""" + if not relation: + return any(map(self.is_ready, self.relations)) + + try: + return bool(self._get_requirer_data(relation)) + except DataValidationError as e: + log.warning("Requirer not ready; validation error encountered: %s" % str(e)) + return False + + def _provided_url(self, relation: Relation) -> ProviderIngressData: # type: ignore + """Fetch and validate this app databag; return the ingress url.""" + assert relation.app, "no app in relation (shouldn't happen)" # for type checker + if not all((relation.app, relation.app.name, self.unit.is_leader())): + # Handle edge case where remote app name can be missing, e.g., + # relation_broken events. + # Also, only leader units can read own app databags. + # FIXME https://github.com/canonical/traefik-k8s-operator/issues/34 + return {} # noqa + + # fetch the provider's app databag + raw_data = relation.data[self.app].get("ingress") + if not raw_data: + raise RuntimeError("This application did not `publish_url` yet.") + + ingress: ProviderIngressData = yaml.safe_load(raw_data) + _validate_data({"ingress": ingress}, INGRESS_PROVIDES_APP_SCHEMA) + return ingress + + def publish_url(self, relation: Relation, url: str): + """Publish to the app databag the ingress url.""" + ingress = {"url": url} + ingress_data = {"ingress": ingress} + _validate_data(ingress_data, INGRESS_PROVIDES_APP_SCHEMA) + relation.data[self.app]["ingress"] = yaml.safe_dump(ingress) + + @property + def proxied_endpoints(self): + """Returns the ingress settings provided to applications by this IngressPerAppProvider. + + For example, when this IngressPerAppProvider has provided the + `http://foo.bar/my-model.my-app` URL to the my-app application, the returned dictionary + will be: + + ``` + { + "my-app": { + "url": "http://foo.bar/my-model.my-app" + } + } + ``` + """ + results = {} + + for ingress_relation in self.relations: + assert ( + ingress_relation.app + ), "no app in relation (shouldn't happen)" # for type checker + results[ingress_relation.app.name] = self._provided_url(ingress_relation) + + return results + + +class IngressPerAppReadyEvent(_IPAEvent): + """Event representing that ingress for an app is ready.""" + + __args__ = ("url",) + if typing.TYPE_CHECKING: + url = None # type: Optional[str] + + +class IngressPerAppRevokedEvent(RelationEvent): + """Event representing that ingress for an app has been revoked.""" + + +class IngressPerAppRequirerEvents(ObjectEvents): + """Container for IPA Requirer events.""" + + ready = EventSource(IngressPerAppReadyEvent) + revoked = EventSource(IngressPerAppRevokedEvent) + + +class IngressPerAppRequirer(_IngressPerAppBase): + """Implementation of the requirer of the ingress relation.""" + + on = IngressPerAppRequirerEvents() + # used to prevent spur1ious urls to be sent out if the event we're currently + # handling is a relation-broken one. + _stored = StoredState() + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + *, + host: Optional[str] = None, + port: Optional[int] = None, + strip_prefix: bool = False, + ): + """Constructor for IngressRequirer. + + The request args can be used to specify the ingress properties when the + instance is created. If any are set, at least `port` is required, and + they will be sent to the ingress provider as soon as it is available. + All request args must be given as keyword args. + + Args: + charm: the charm that is instantiating the library. + relation_name: the name of the relation endpoint to bind to (defaults to `ingress`); + relation must be of interface type `ingress` and have "limit: 1") + host: Hostname to be used by the ingress provider to address the requiring + application; if unspecified, the default Kubernetes service name will be used. + strip_prefix: configure Traefik to strip the path prefix. + + Request Args: + port: the port of the service + """ + super().__init__(charm, relation_name) + self.charm: CharmBase = charm + self.relation_name = relation_name + self._strip_prefix = strip_prefix + + self._stored.set_default(current_url=None) # type: ignore + + # if instantiated with a port, and we are related, then + # we immediately publish our ingress data to speed up the process. + if port: + self._auto_data = host, port + else: + self._auto_data = None + + def _handle_relation(self, event): + # created, joined or changed: if we have auto data: publish it + self._publish_auto_data(event.relation) + + if self.is_ready(): + # Avoid spurious events, emit only when there is a NEW URL available + new_url = ( + None + if isinstance(event, RelationBrokenEvent) + else self._get_url_from_relation_data() + ) + if self._stored.current_url != new_url: # type: ignore + self._stored.current_url = new_url # type: ignore + self.on.ready.emit(event.relation, new_url) # type: ignore + + def _handle_relation_broken(self, event): + self._stored.current_url = None # type: ignore + self.on.revoked.emit(event.relation) # type: ignore + + def _handle_upgrade_or_leader(self, event): + """On upgrade/leadership change: ensure we publish the data we have.""" + for relation in self.relations: + self._publish_auto_data(relation) + + def is_ready(self): + """The Requirer is ready if the Provider has sent valid data.""" + try: + return bool(self._get_url_from_relation_data()) + except DataValidationError as e: + log.warning("Requirer not ready; validation error encountered: %s" % str(e)) + return False + + def _publish_auto_data(self, relation: Relation): + if self._auto_data and self.unit.is_leader(): + host, port = self._auto_data + self.provide_ingress_requirements(host=host, port=port) + + def provide_ingress_requirements(self, *, host: Optional[str] = None, port: int): + """Publishes the data that Traefik needs to provide ingress. + + NB only the leader unit is supposed to do this. + + Args: + host: Hostname to be used by the ingress provider to address the + requirer unit; if unspecified, FQDN will be used instead + port: the port of the service (required) + """ + # get only the leader to publish the data since we only + # require one unit to publish it -- it will not differ between units, + # unlike in ingress-per-unit. + assert self.unit.is_leader(), "only leaders should do this." + assert self.relation, "no relation" + + if not host: + host = socket.getfqdn() + + data = { + "model": self.model.name, + "name": self.app.name, + "host": host, + "port": str(port), + } + + if self._strip_prefix: + data["strip-prefix"] = "true" + + _validate_data(data, INGRESS_REQUIRES_APP_SCHEMA) + self.relation.data[self.app].update(data) + + @property + def relation(self): + """The established Relation instance, or None.""" + return self.relations[0] if self.relations else None + + def _get_url_from_relation_data(self) -> Optional[str]: + """The full ingress URL to reach the current unit. + + Returns None if the URL isn't available yet. + """ + relation = self.relation + if not relation: + return None + + # fetch the provider's app databag + try: + assert relation.app, "no app in relation (shouldn't happen)" # for type checker + raw = relation.data.get(relation.app, {}).get("ingress") + except ModelError as e: + log.debug( + f"Error {e} attempting to read remote app data; " + f"probably we are in a relation_departed hook" + ) + return None + + if not raw: + return None + + ingress: ProviderIngressData = yaml.safe_load(raw) + _validate_data({"ingress": ingress}, INGRESS_PROVIDES_APP_SCHEMA) + return ingress["url"] + + @property + def url(self) -> Optional[str]: + """The full ingress URL to reach the current unit. + + Returns None if the URL isn't available yet. + """ + data = self._stored.current_url or None # type: ignore + assert isinstance(data, (str, type(None))) # for static checker + return data diff --git a/metadata.yaml b/metadata.yaml index 25f520c7..33e04973 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -18,3 +18,7 @@ requires: pg-database: interface: postgresql_client optional: false + public-ingress: + interface: ingress + admin-ingress: + interface: ingress diff --git a/requirements.txt b/requirements.txt index f9d0f7f7..dba8d58c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ ops ==1.5.3 lightkube lightkube-models Jinja2 +jsonschema diff --git a/src/charm.py b/src/charm.py index 3a57176e..52a284f7 100755 --- a/src/charm.py +++ b/src/charm.py @@ -10,6 +10,11 @@ from charms.data_platform_libs.v0.database_requires import DatabaseCreatedEvent, DatabaseRequires from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch +from charms.traefik_k8s.v1.ingress import ( + IngressPerAppReadyEvent, + IngressPerAppRequirer, + IngressPerAppRevokedEvent, +) from jinja2 import Template from ops.charm import CharmBase from ops.main import main @@ -17,6 +22,8 @@ from ops.pebble import ChangeError, ExecError, Layer logger = logging.getLogger(__name__) +KRATOS_ADMIN_PORT = 4434 +KRATOS_PUBLIC_PORT = 4433 class KratosCharm(CharmBase): @@ -31,7 +38,21 @@ def __init__(self, *args): self._identity_schema_file_path = f"{self._config_dir_path}/identity.default.schema.json" self._db_name = f"{self.model.name}_{self.app.name}" - self.service_patcher = KubernetesServicePatch(self, [("admin", 4434), ("public", 4433)]) + self.service_patcher = KubernetesServicePatch( + self, [("admin", KRATOS_ADMIN_PORT), ("public", KRATOS_PUBLIC_PORT)] + ) + self.admin_ingress = IngressPerAppRequirer( + self, + relation_name="admin-ingress", + port=KRATOS_ADMIN_PORT, + strip_prefix=True, + ) + self.public_ingress = IngressPerAppRequirer( + self, + relation_name="public-ingress", + port=KRATOS_PUBLIC_PORT, + strip_prefix=True, + ) self.database = DatabaseRequires( self, @@ -43,6 +64,10 @@ def __init__(self, *args): self.framework.observe(self.on.kratos_pebble_ready, self._on_pebble_ready) self.framework.observe(self.database.on.database_created, self._on_database_changed) self.framework.observe(self.database.on.endpoints_changed, self._on_database_changed) + self.framework.observe(self.admin_ingress.on.ready, self._on_admin_ingress_ready) + self.framework.observe(self.admin_ingress.on.revoked, self._on_ingress_revoked) + self.framework.observe(self.public_ingress.on.ready, self._on_public_ingress_ready) + self.framework.observe(self.public_ingress.on.revoked, self._on_ingress_revoked) @property def _pebble_layer(self) -> Layer: @@ -175,6 +200,18 @@ def _on_database_changed(self, event: DatabaseCreatedEvent) -> None: self.unit.status = MaintenanceStatus("Retrieving database details") self._update_container(event) + def _on_admin_ingress_ready(self, event: IngressPerAppReadyEvent) -> None: + if self.unit.is_leader(): + logger.info("This app's admin ingress URL: %s", event.url) + + def _on_public_ingress_ready(self, event: IngressPerAppReadyEvent) -> None: + if self.unit.is_leader(): + logger.info("This app's public ingress URL: %s", event.url) + + def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent) -> None: + if self.unit.is_leader(): + logger.info("This app no longer has ingress") + if __name__ == "__main__": main(KratosCharm) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 81b40d8a..7b74d23a 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +import requests import yaml from pytest_operator.plugin import OpsTest @@ -14,6 +15,15 @@ METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) APP_NAME = METADATA["name"] POSTGRES = "postgresql-k8s" +TRAEFIK = "traefik-k8s" +TRAEFIK_ADMIN_APP = "traefik-admin" +TRAEFIK_PUBLIC_APP = "traefik-public" + + +async def get_unit_address(ops_test: OpsTest, app_name: str, unit_num: int) -> str: + """Get private address of a unit.""" + status = await ops_test.model.get_status() # noqa: F821 + return status["applications"][app_name]["units"][f"{app_name}/{unit_num}"]["address"] @pytest.mark.abort_on_fail @@ -42,3 +52,49 @@ async def test_build_and_deploy(ops_test: OpsTest): timeout=1000, ) assert ops_test.model.applications[APP_NAME].units[0].workload_status == "active" + + +async def test_ingress_relation(ops_test: OpsTest): + await ops_test.model.deploy( + TRAEFIK, + application_name=TRAEFIK_PUBLIC_APP, + channel="latest/edge", + config={"external_hostname": "some_hostname"}, + ) + await ops_test.model.deploy( + TRAEFIK, + application_name=TRAEFIK_ADMIN_APP, + channel="latest/edge", + config={"external_hostname": "some_hostname"}, + ) + await ops_test.model.add_relation(f"{APP_NAME}:admin-ingress", TRAEFIK_ADMIN_APP) + await ops_test.model.add_relation(f"{APP_NAME}:public-ingress", TRAEFIK_PUBLIC_APP) + + await ops_test.model.wait_for_idle( + apps=[TRAEFIK_PUBLIC_APP, TRAEFIK_ADMIN_APP], + status="active", + raise_on_blocked=True, + timeout=1000, + ) + + +async def test_has_public_ingress(ops_test: OpsTest): + # Get the traefik address and try to reach kratos + public_address = await get_unit_address(ops_test, TRAEFIK_PUBLIC_APP, 0) + + resp = requests.get( + f"http://{public_address}/{ops_test.model.name}-{APP_NAME}/.well-known/ory/webauthn.js" + ) + + assert resp.status_code == 200 + + +async def test_has_admin_ingress(ops_test: OpsTest): + # Get the traefik address and try to reach kratos + admin_address = await get_unit_address(ops_test, TRAEFIK_ADMIN_APP, 0) + + resp = requests.get( + f"http://{admin_address}/{ops_test.model.name}-{APP_NAME}/admin/identities" + ) + + assert resp.status_code == 200 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 035a12e2..9c11de6d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -22,6 +22,13 @@ def mocked_kubernetes_service_patcher(mocker): yield mocked_service_patcher +@pytest.fixture() +def mocked_fqdn(mocker): + mocked_fqdn = mocker.patch("socket.getfqdn") + mocked_fqdn.return_value = "kratos" + return mocked_fqdn + + @pytest.fixture() def mocked_sql_migration(mocker): mocked_sql_migration = mocker.patch("charm.KratosCharm._run_sql_migration") diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 55ac350d..5576ed44 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -1,6 +1,7 @@ # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. +import pytest import yaml from ops.model import ActiveStatus, BlockedStatus, WaitingStatus @@ -25,6 +26,17 @@ def setup_postgres_relation(harness): ) +def setup_ingress_relation(harness, type): + relation_id = harness.add_relation(f"{type}-ingress", f"{type}-traefik") + harness.add_relation_unit(relation_id, f"{type}-traefik/0") + harness.update_relation_data( + relation_id, + f"{type}-traefik", + {"url": f"http://{type}:80/{harness.model.name}-kratos"}, + ) + return relation_id + + def test_update_container_correct_config( harness, mocked_kubernetes_service_patcher, mocked_sql_migration ) -> None: @@ -141,3 +153,22 @@ def test_on_database_created( setup_postgres_relation(harness) mocked_update_container.assert_called_once() + + +@pytest.mark.parametrize("api_type,port", [("admin", "4434"), ("public", "4433")]) +def test_ingress_relation_created( + harness, mocked_kubernetes_service_patcher, mocked_fqdn, api_type, port +) -> None: + harness.begin() + harness.set_can_connect(CONTAINER_NAME, True) + + relation_id = setup_ingress_relation(harness, api_type) + app_data = harness.get_relation_data(relation_id, harness.charm.app) + + assert app_data == { + "host": mocked_fqdn.return_value, + "model": harness.model.name, + "name": "kratos", + "port": port, + "strip-prefix": "true", + } diff --git a/tox.ini b/tox.ini index af5bc98a..eebf64af 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ envlist = fmt, lint, unit src_path = {toxinidir}/src/ tst_path = {toxinidir}/tests/ lib_path = {toxinidir}/lib/charms/ -all_path = {[vars]src_path} {[vars]tst_path} +all_path = {[vars]src_path} {[vars]tst_path} [testenv] setenv = @@ -36,7 +36,7 @@ deps = -r{toxinidir}/lint-requirements.txt commands = codespell {[vars]lib_path} - codespell {toxinidir}/. --skip {toxinidir}/.git --skip {toxinidir}/.tox \ + codespell {toxinidir}/ --skip {toxinidir}/.git --skip {toxinidir}/.tox \ --skip {toxinidir}/build --skip {toxinidir}/lib --skip {toxinidir}/venv \ --skip {toxinidir}/.mypy_cache --skip {toxinidir}/icon.svg # pflake8 wrapper supports config from pyproject.toml