From a947fc1c5a28b86064d868caf90b526a3656994f Mon Sep 17 00:00:00 2001 From: yazansalti Date: Thu, 29 Aug 2024 10:05:11 +0200 Subject: [PATCH 01/10] Adds the ingress integration --- charmcraft.yaml | 3 + lib/charms/traefik_k8s/v2/ingress.py | 849 +++++++++++++++++++++++++++ src/charm.py | 7 + tests/integration/test_charm.py | 16 + 4 files changed, 875 insertions(+) create mode 100644 lib/charms/traefik_k8s/v2/ingress.py diff --git a/charmcraft.yaml b/charmcraft.yaml index 82b0e82..2d73458 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -30,6 +30,9 @@ provides: requires: logging: interface: loki_push_api + ingress: + interface: ingress + limit: 1 bases: - build-on: diff --git a/lib/charms/traefik_k8s/v2/ingress.py b/lib/charms/traefik_k8s/v2/ingress.py new file mode 100644 index 0000000..bb7ac5e --- /dev/null +++ b/lib/charms/traefik_k8s/v2/ingress.py @@ -0,0 +1,849 @@ +# Copyright 2024 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.v2.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.v2.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 ipaddress +import json +import logging +import socket +import typing +from dataclasses import dataclass +from functools import partial +from typing import Any, Callable, Dict, List, MutableMapping, Optional, Sequence, Tuple, Union + +import pydantic +from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent +from ops.framework import EventSource, Object, ObjectEvents, StoredState +from ops.model import ModelError, Relation, Unit +from pydantic import AnyHttpUrl, BaseModel, Field + +# The unique Charmhub library identifier, never change it +LIBID = "e6de2a5cd5b34422a204668f3b8f90d2" + +# Increment this major API version when introducing breaking changes +LIBAPI = 2 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 14 + +PYDEPS = ["pydantic"] + +DEFAULT_RELATION_NAME = "ingress" +RELATION_INTERFACE = "ingress" + +log = logging.getLogger(__name__) +BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"} + +PYDANTIC_IS_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2 +if PYDANTIC_IS_V1: + from pydantic import validator + + input_validator = partial(validator, pre=True) + + class DatabagModel(BaseModel): # type: ignore + """Base databag model.""" + + class Config: + """Pydantic config.""" + + allow_population_by_field_name = True + """Allow instantiating this class by field name (instead of forcing alias).""" + + _NEST_UNDER = None + + @classmethod + def load(cls, databag: MutableMapping): + """Load this model from a Juju databag.""" + if cls._NEST_UNDER: + return cls.parse_obj(json.loads(databag[cls._NEST_UNDER])) + + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {f.alias for f in cls.__fields__.values()} # type: ignore + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + log.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.parse_raw(json.dumps(data)) # type: ignore + except pydantic.ValidationError as e: + msg = f"failed to validate databag: {databag}" + log.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + """Write the contents of this model to Juju databag. + + :param databag: the databag to write the data to. + :param clear: ensure the databag is cleared before writing it. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + + if self._NEST_UNDER: + databag[self._NEST_UNDER] = self.json(by_alias=True, exclude_defaults=True) + return databag + + for key, value in self.dict(by_alias=True, exclude_defaults=True).items(): # type: ignore + databag[key] = json.dumps(value) + + return databag + +else: + from pydantic import ConfigDict, field_validator + + input_validator = partial(field_validator, mode="before") + + class DatabagModel(BaseModel): + """Base databag model.""" + + model_config = ConfigDict( + # tolerate additional keys in databag + extra="ignore", + # Allow instantiating this class by field name (instead of forcing alias). + populate_by_name=True, + # Custom config key: whether to nest the whole datastructure (as json) + # under a field or spread it out at the toplevel. + _NEST_UNDER=None, + ) # type: ignore + """Pydantic config.""" + + @classmethod + def load(cls, databag: MutableMapping): + """Load this model from a Juju databag.""" + nest_under = cls.model_config.get("_NEST_UNDER") + if nest_under: + return cls.model_validate(json.loads(databag[nest_under])) # type: ignore + + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {(f.alias or n) for n, f in cls.model_fields.items()} # type: ignore + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + log.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.model_validate_json(json.dumps(data)) # type: ignore + except pydantic.ValidationError as e: + msg = f"failed to validate databag: {databag}" + log.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + """Write the contents of this model to Juju databag. + + :param databag: the databag to write the data to. + :param clear: ensure the databag is cleared before writing it. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + nest_under = self.model_config.get("_NEST_UNDER") + if nest_under: + databag[nest_under] = self.model_dump_json( # type: ignore + by_alias=True, + # skip keys whose values are default + exclude_defaults=True, + ) + return databag + + dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=True) # type: ignore + databag.update({k: json.dumps(v) for k, v in dct.items()}) + return databag + + +# todo: import these models from charm-relation-interfaces/ingress/v2 instead of redeclaring them +class IngressUrl(BaseModel): + """Ingress url schema.""" + + url: AnyHttpUrl + + +class IngressProviderAppData(DatabagModel): + """Ingress application databag schema.""" + + ingress: IngressUrl + + +class ProviderSchema(BaseModel): + """Provider schema for Ingress.""" + + app: IngressProviderAppData + + +class IngressRequirerAppData(DatabagModel): + """Ingress requirer application databag model.""" + + model: str = Field(description="The model the application is in.") + name: str = Field(description="the name of the app requesting ingress.") + port: int = Field(description="The port the app wishes to be exposed.") + + # fields on top of vanilla 'ingress' interface: + strip_prefix: Optional[bool] = Field( + default=False, + description="Whether to strip the prefix from the ingress url.", + alias="strip-prefix", + ) + redirect_https: Optional[bool] = Field( + default=False, + description="Whether to redirect http traffic to https.", + alias="redirect-https", + ) + + scheme: Optional[str] = Field( + default="http", description="What scheme to use in the generated ingress url" + ) + + @input_validator("scheme") + def validate_scheme(cls, scheme): # noqa: N805 # pydantic wants 'cls' as first arg + """Validate scheme arg.""" + if scheme not in {"http", "https", "h2c"}: + raise ValueError("invalid scheme: should be one of `http|https|h2c`") + return scheme + + @input_validator("port") + def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg + """Validate port.""" + assert isinstance(port, int), type(port) + assert 0 < port < 65535, "port out of TCP range" + return port + + +class IngressRequirerUnitData(DatabagModel): + """Ingress requirer unit databag model.""" + + host: str = Field(description="Hostname at which the unit is reachable.") + ip: Optional[str] = Field( + None, + description="IP at which the unit is reachable, " + "IP can only be None if the IP information can't be retrieved from juju.", + ) + + @input_validator("host") + def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg + """Validate host.""" + assert isinstance(host, str), type(host) + return host + + @input_validator("ip") + def validate_ip(cls, ip): # noqa: N805 # pydantic wants 'cls' as first arg + """Validate ip.""" + if ip is None: + return None + if not isinstance(ip, str): + raise TypeError(f"got ip of type {type(ip)} instead of expected str") + try: + ipaddress.IPv4Address(ip) + return ip + except ipaddress.AddressValueError: + pass + try: + ipaddress.IPv6Address(ip) + return ip + except ipaddress.AddressValueError: + raise ValueError(f"{ip!r} is not a valid ip address") + + +class RequirerSchema(BaseModel): + """Requirer schema for Ingress.""" + + app: IngressRequirerAppData + unit: IngressRequirerUnitData + + +class IngressError(RuntimeError): + """Base class for custom errors raised by this library.""" + + +class NotReadyError(IngressError): + """Raised when a relation is not ready.""" + + +class DataValidationError(IngressError): + """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_departed, self._handle_relation) + observe(rel_events.relation_broken, self._handle_relation_broken) + observe(charm.on.leader_elected, self._handle_upgrade_or_leader) # type: ignore + observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore + + @property + def relations(self): + """The list of Relation instances associated with this endpoint.""" + return list(self.charm.model.relations[self.relation_name]) + + def _handle_relation(self, event): + """Subclasses should implement this method to handle a relation update.""" + pass + + def _handle_relation_broken(self, event): + """Subclasses should implement this method to handle a relation breaking.""" + pass + + def _handle_upgrade_or_leader(self, event): + """Subclasses should implement this method to handle upgrades or leadership change.""" + pass + + +class _IPAEvent(RelationEvent): + __args__: Tuple[str, ...] = () + __optional_kwargs__: Dict[str, Any] = {} + + @classmethod + def __attrs__(cls): + return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) + + def __init__(self, handle, relation, *args, **kwargs): + super().__init__(handle, relation) + + if not len(self.__args__) == len(args): + raise TypeError("expected {} args, got {}".format(len(self.__args__), len(args))) + + for attr, obj in zip(self.__args__, args): + setattr(self, attr, obj) + for attr, default in self.__optional_kwargs__.items(): + obj = kwargs.get(attr, default) + setattr(self, attr, obj) + + def snapshot(self): + dct = super().snapshot() + for attr in self.__attrs__(): + obj = getattr(self, attr) + try: + dct[attr] = obj + except ValueError as e: + raise ValueError( + "cannot automagically serialize {}: " + "override this method and do it " + "manually.".format(obj) + ) from e + + return dct + + def restore(self, snapshot) -> None: + super().restore(snapshot) + for attr, obj in snapshot.items(): + setattr(self, attr, obj) + + +class IngressPerAppDataProvidedEvent(_IPAEvent): + """Event representing that ingress data has been provided for an app.""" + + __args__ = ("name", "model", "hosts", "strip_prefix", "redirect_https") + + if typing.TYPE_CHECKING: + name: Optional[str] = None + model: Optional[str] = None + # sequence of hostname, port dicts + hosts: Sequence["IngressRequirerUnitData"] = () + strip_prefix: bool = False + redirect_https: bool = False + + +class IngressPerAppDataRemovedEvent(RelationEvent): + """Event representing that ingress data has been removed for an app.""" + + +class IngressPerAppProviderEvents(ObjectEvents): + """Container for IPA Provider events.""" + + data_provided = EventSource(IngressPerAppDataProvidedEvent) + data_removed = EventSource(IngressPerAppDataRemovedEvent) + + +@dataclass +class IngressRequirerData: + """Data exposed by the ingress requirer to the provider.""" + + app: "IngressRequirerAppData" + units: List["IngressRequirerUnitData"] + + +class IngressPerAppProvider(_IngressPerAppBase): + """Implementation of the provider of ingress.""" + + on = IngressPerAppProviderEvents() # type: ignore + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + ): + """Constructor for IngressPerAppProvider. + + Args: + charm: The charm that is instantiating the instance. + relation_name: The name of the relation endpoint to bind to + (defaults to "ingress"). + """ + super().__init__(charm, relation_name) + + def _handle_relation(self, event): + # created, joined or changed: if remote side has sent the required data: + # notify listeners. + if self.is_ready(event.relation): + data = self.get_data(event.relation) + self.on.data_provided.emit( # type: ignore + event.relation, + data.app.name, + data.app.model, + [ + unit.dict() if PYDANTIC_IS_V1 else unit.model_dump(mode="json") + for unit in data.units + ], + data.app.strip_prefix or False, + data.app.redirect_https or 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_units_data(self, relation: Relation) -> List["IngressRequirerUnitData"]: + """Fetch and validate the requirer's app databag.""" + out: List["IngressRequirerUnitData"] = [] + + unit: Unit + for unit in relation.units: + databag = relation.data[unit] + try: + data = IngressRequirerUnitData.load(databag) + out.append(data) + except pydantic.ValidationError: + log.info(f"failed to validate remote unit data for {unit}") + raise + return out + + @staticmethod + def _get_requirer_app_data(relation: Relation) -> "IngressRequirerAppData": + """Fetch and validate the requirer's app databag.""" + app = relation.app + if app is None: + raise NotReadyError(relation) + + databag = relation.data[app] + return IngressRequirerAppData.load(databag) + + def get_data(self, relation: Relation) -> IngressRequirerData: + """Fetch the remote (requirer) app and units' databags.""" + try: + return IngressRequirerData( + self._get_requirer_app_data(relation), self._get_requirer_units_data(relation) + ) + except (pydantic.ValidationError, DataValidationError) as e: + raise DataValidationError("failed to validate ingress requirer data") from e + + 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: + self.get_data(relation) + except (DataValidationError, NotReadyError) as e: + log.debug("Provider not ready; validation error encountered: %s" % str(e)) + return False + return True + + def _published_url(self, relation: Relation) -> Optional["IngressProviderAppData"]: + """Fetch and validate this app databag; return the ingress url.""" + if not self.is_ready(relation) or not 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 None + + # fetch the provider's app databag + databag = relation.data[self.app] + if not databag.get("ingress"): + raise NotReadyError("This application did not `publish_url` yet.") + + return IngressProviderAppData.load(databag) + + def publish_url(self, relation: Relation, url: str): + """Publish to the app databag the ingress url.""" + ingress_url = {"url": url} + IngressProviderAppData(ingress=ingress_url).dump(relation.data[self.app]) # type: ignore + + @property + def proxied_endpoints(self) -> Dict[str, Dict[str, str]]: + """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: Dict[str, Dict[str, str]] = {} + + for ingress_relation in self.relations: + if not ingress_relation.app: + log.warning( + f"no app in relation {ingress_relation} when fetching proxied endpoints: skipping" + ) + continue + try: + ingress_data = self._published_url(ingress_relation) + except NotReadyError: + log.warning( + f"no published url found in {ingress_relation}: " + f"traefik didn't publish_url yet to this relation." + ) + continue + + if not ingress_data: + log.warning(f"relation {ingress_relation} not ready yet: try again in some time.") + continue + if PYDANTIC_IS_V1: + results[ingress_relation.app.name] = ingress_data.ingress.dict() + else: + results[ingress_relation.app.name] = ingress_data.ingress.model_dump(mode="json") + return results + + +class IngressPerAppReadyEvent(_IPAEvent): + """Event representing that ingress for an app is ready.""" + + __args__ = ("url",) + if typing.TYPE_CHECKING: + url: Optional[str] = None + + +class IngressPerAppRevokedEvent(RelationEvent): + """Event representing that ingress for an app has been revoked.""" + + +class IngressPerAppRequirerEvents(ObjectEvents): + """Container for IPA Requirer events.""" + + ready = EventSource(IngressPerAppReadyEvent) + revoked = EventSource(IngressPerAppRevokedEvent) + + +class IngressPerAppRequirer(_IngressPerAppBase): + """Implementation of the requirer of the ingress relation.""" + + on = IngressPerAppRequirerEvents() # type: ignore + + # used to prevent spurious urls to be sent out if the event we're currently + # handling is a relation-broken one. + _stored = StoredState() + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + *, + host: Optional[str] = None, + ip: Optional[str] = None, + port: Optional[int] = None, + strip_prefix: bool = False, + redirect_https: bool = False, + # fixme: this is horrible UX. + # shall we switch to manually calling provide_ingress_requirements with all args when ready? + scheme: Union[Callable[[], str], str] = lambda: "http", + ): + """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. + ip: Alternative addressing method other than host to be used by the ingress provider; + if unspecified, binding address from juju network API will be used. + strip_prefix: configure Traefik to strip the path prefix. + redirect_https: redirect incoming requests to HTTPS. + scheme: callable returning the scheme to use when constructing the ingress url. + Or a string, if the scheme is known and stable at charm-init-time. + + Request Args: + port: the port of the service + """ + super().__init__(charm, relation_name) + self.charm: CharmBase = charm + self.relation_name = relation_name + self._strip_prefix = strip_prefix + self._redirect_https = redirect_https + self._get_scheme = scheme if callable(scheme) else lambda: scheme + + 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, ip, 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() + 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.""" + self._publish_auto_data() + + 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.debug("Requirer not ready; validation error encountered: %s" % str(e)) + return False + + def _publish_auto_data(self): + if self._auto_data: + host, ip, port = self._auto_data + self.provide_ingress_requirements(host=host, ip=ip, port=port) + + def provide_ingress_requirements( + self, + *, + scheme: Optional[str] = None, + host: Optional[str] = None, + ip: Optional[str] = None, + port: int, + ): + """Publishes the data that Traefik needs to provide ingress. + + Args: + scheme: Scheme to be used; if unspecified, use the one used by __init__. + host: Hostname to be used by the ingress provider to address the + requirer unit; if unspecified, FQDN will be used instead + ip: Alternative addressing method other than host to be used by the ingress provider. + if unspecified, binding address from juju network API will be used. + port: the port of the service (required) + """ + for relation in self.relations: + self._provide_ingress_requirements(scheme, host, ip, port, relation) + + def _provide_ingress_requirements( + self, + scheme: Optional[str], + host: Optional[str], + ip: Optional[str], + port: int, + relation: Relation, + ): + if self.unit.is_leader(): + self._publish_app_data(scheme, port, relation) + + self._publish_unit_data(host, ip, relation) + + def _publish_unit_data( + self, + host: Optional[str], + ip: Optional[str], + relation: Relation, + ): + if not host: + host = socket.getfqdn() + + if ip is None: + network_binding = self.charm.model.get_binding(relation) + if ( + network_binding is not None + and (bind_address := network_binding.network.bind_address) is not None + ): + ip = str(bind_address) + else: + log.error("failed to retrieve ip information from juju") + + unit_databag = relation.data[self.unit] + try: + IngressRequirerUnitData(host=host, ip=ip).dump(unit_databag) + except pydantic.ValidationError as e: + msg = "failed to validate unit data" + log.info(msg, exc_info=True) # log to INFO because this might be expected + raise DataValidationError(msg) from e + + def _publish_app_data( + self, + scheme: Optional[str], + port: int, + relation: Relation, + ): + # assumes leadership! + app_databag = relation.data[self.app] + + if not scheme: + # If scheme was not provided, use the one given to the constructor. + scheme = self._get_scheme() + + try: + IngressRequirerAppData( # type: ignore # pyright does not like aliases + model=self.model.name, + name=self.app.name, + scheme=scheme, + port=port, + strip_prefix=self._strip_prefix, # type: ignore # pyright does not like aliases + redirect_https=self._redirect_https, # type: ignore # pyright does not like aliases + ).dump(app_databag) + except pydantic.ValidationError as e: + msg = "failed to validate app data" + log.info(msg, exc_info=True) # log to INFO because this might be expected + raise DataValidationError(msg) from e + + @property + def relation(self): + """The established Relation instance, or None.""" + return self.relations[0] if self.relations else None + + def _get_url_from_relation_data(self) -> Optional[str]: + """The full ingress URL to reach the current unit. + + Returns None if the URL isn't available yet. + """ + relation = self.relation + if not relation or not relation.app: + return None + + # fetch the provider's app databag + try: + databag = relation.data[relation.app] + 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 databag: # not ready yet + return None + + return str(IngressProviderAppData.load(databag).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 = ( + typing.cast(Optional[str], self._stored.current_url) # type: ignore + or self._get_url_from_relation_data() + ) + return data diff --git a/src/charm.py b/src/charm.py index a598d55..b709e6a 100755 --- a/src/charm.py +++ b/src/charm.py @@ -22,6 +22,7 @@ generate_csr, generate_private_key, ) +from charms.traefik_k8s.v2.ingress import IngressPerAppRequirer from gocert import GoCert @@ -70,6 +71,12 @@ def __init__(self, framework: ops.Framework): self.tls = TLSCertificatesProvidesV4(self, relationship_name="certificates") self.dashboard = GrafanaDashboardProvider(self, relation_name=GRAFANA_RELATION_NAME) self.logs = LogForwarder(charm=self, relation_name=LOGGING_RELATION_NAME) + self.ingress = IngressPerAppRequirer( + charm=self, + port=self.port, + strip_prefix=True, + scheme=lambda: "https", + ) self.metrics = MetricsEndpointProvider( charm=self, relation_name=METRICS_RELATION_NAME, diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 1e80b70..fc79874 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -17,6 +17,7 @@ LOKI_APPLICATION_NAME = "loki-k8s" PROMETHEUS_APPLICATION_NAME = "prometheus-k8s" +TRAEIK_K8S_APPLICATION_NAME = "traefik-k8s" @pytest.mark.abort_on_fail @@ -69,3 +70,18 @@ async def test_given_loki_and_prometheus_related_to_gocert_all_charm_statuses_ac timeout=1000, raise_on_error=True, ) + + +@pytest.mark.abort_on_fail +async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_statuses_active( + ops_test: OpsTest, +): + await ops_test.model.deploy( + TRAEIK_K8S_APPLICATION_NAME, application_name=TRAEIK_K8S_APPLICATION_NAME, trust=True + ) + await ops_test.model.wait_for_idle( + apps=[APP_NAME, TRAEIK_K8S_APPLICATION_NAME], + status="active", + timeout=1000, + raise_on_error=True, + ) From 205480872b755968a7dce7dc1be5a88529056f9d Mon Sep 17 00:00:00 2001 From: yazansalti Date: Thu, 29 Aug 2024 10:41:21 +0200 Subject: [PATCH 02/10] Enable metallb --- .github/workflows/integration-test.yaml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 65d40fd..2bd6c7d 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -10,7 +10,7 @@ jobs: arch: - arch: amd64 runner: ubuntu-22.04 - + runs-on: ${{ matrix.arch.runner }} steps: - name: Checkout @@ -21,11 +21,11 @@ jobs: with: name: built-charm-${{ matrix.arch.arch }} path: built/ - + - name: Get Charm Under Test Path id: charm-path run: echo "charm_path=$(find built/ -name '*.charm' -type f -print)" >> $GITHUB_OUTPUT - + - name: Setup operator environment uses: charmed-kubernetes/actions-operator@main with: @@ -33,19 +33,22 @@ jobs: channel: 1.29-strict/stable juju-channel: 3.4/stable lxd-channel: 5.20/stable - + + - name: Enable Metallb + run: /usr/bin/sg snap_microk8s -c "sudo microk8s enable metallb:10.0.0.2-10.0.0.10" + - name: Run integration tests run: | tox -e integration -- \ --charm_path=${{ steps.charm-path.outputs.charm_path }} - + - name: Archive charmcraft logs if: failure() uses: actions/upload-artifact@v4 with: name: charmcraft-logs path: /home/runner/.local/state/charmcraft/log/*.log - + - name: Archive juju crashdump if: failure() uses: actions/upload-artifact@v4 From 821b04f94c08d3e026cd37f1461088ae9356173a Mon Sep 17 00:00:00 2001 From: yazansalti Date: Mon, 23 Sep 2024 13:36:41 +0200 Subject: [PATCH 03/10] Implements the cert transfer interface --- charmcraft.yaml | 4 + .../v1/certificate_transfer.py | 455 ++++++++++++++++++ src/charm.py | 19 + tests/unit/test_charm.py | 18 +- 4 files changed, 489 insertions(+), 7 deletions(-) create mode 100644 lib/charms/certificate_transfer_interface/v1/certificate_transfer.py diff --git a/charmcraft.yaml b/charmcraft.yaml index addfbab..56ae204 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -29,6 +29,10 @@ provides: interface: prometheus_scrape grafana-dashboard: interface: grafana_dashboard + send-ca-cert: + interface: certificate_transfer + description: | + Send our CA certificate so clients can trust the CA by means of forming a relation. requires: access-certificates: diff --git a/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py b/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py new file mode 100644 index 0000000..5213484 --- /dev/null +++ b/lib/charms/certificate_transfer_interface/v1/certificate_transfer.py @@ -0,0 +1,455 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the certificate_transfer relation. + +This library contains the Requires and Provides classes for handling the +certificate-transfer interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.certificate_transfer_interface.v1.certificate_transfer +``` + +### Provider charm +The provider charm is the charm providing public certificates to another charm that requires them. + +Example: +```python +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main + +from lib.charms.certificate_transfer_interface.v1.certificate_transfer import ( + CertificateTransferProvides, +) + +class DummyCertificateTransferProviderCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.certificate_transfer = CertificateTransferProvides(self, "certificates") + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent): + certificate = "my certificate" + self.certificate_transfer.add_certificates(certificate) + + +if __name__ == "__main__": + main(DummyCertificateTransferProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. + +Example: +```python +import logging + +from ops.charm import CharmBase +from ops.main import main + +from lib.charms.certificate_transfer_interface.v1.certificate_transfer import ( + CertificatesAvailableEvent, + CertificatesRemovedEvent, + CertificateTransferRequires, +) + + +class DummyCertificateTransferRequirerCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.certificate_transfer = CertificateTransferRequires(self, "certificates") + self.framework.observe( + self.certificate_transfer.on.certificate_set_updated, self._on_certificates_available + ) + self.framework.observe( + self.certificate_transfer.on.certificates_removed, self._on_certificates_removed + ) + + def _on_certificates_available(self, event: CertificatesAvailableEvent): + logging.info(event.certificates) + logging.info(event.relation_id) + + def _on_certificates_removed(self, event: CertificatesRemovedEvent): + logging.info(event.relation_id) + + +if __name__ == "__main__": + main(DummyCertificateTransferRequirerCharm) +``` + +You can integrate both charms by running: + +```bash +juju integrate +``` + +""" + +import json +import logging +from typing import List, MutableMapping, Optional, Set + +from ops import ( + CharmEvents, + EventBase, + EventSource, + Handle, + Relation, + RelationBrokenEvent, + RelationChangedEvent, +) +from ops.charm import CharmBase +from ops.framework import Object +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +# The unique Charmhub library identifier, never change it +LIBID = "3785165b24a743f2b0c60de52db25c8b" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +logger = logging.getLogger(__name__) + +PYDEPS = ["pydantic"] + + +class TLSCertificatesError(Exception): + """Base class for custom errors raised by this library.""" + + +class DataValidationError(TLSCertificatesError): + """Raised when data validation fails.""" + + +class DatabagModel(BaseModel): + """Base databag model.""" + + model_config = ConfigDict( + # tolerate additional keys in databag + extra="ignore", + # Allow instantiating this class by field name (instead of forcing alias). + populate_by_name=True, + # Custom config key: whether to nest the whole datastructure (as json) + # under a field or spread it out at the toplevel. + _NEST_UNDER=None, + ) # type: ignore + """Pydantic config.""" + + @classmethod + def load(cls, databag: MutableMapping): + """Load this model from a Juju databag.""" + nest_under = cls.model_config.get("_NEST_UNDER") + if nest_under: + return cls.model_validate(json.loads(databag[nest_under])) + + try: + data = { + k: json.loads(v) + for k, v in databag.items() + # Don't attempt to parse model-external values + if k in {(f.alias or n) for n, f in cls.model_fields.items()} + } + except json.JSONDecodeError as e: + msg = f"invalid databag contents: expecting json. {databag}" + logger.error(msg) + raise DataValidationError(msg) from e + + try: + return cls.model_validate_json(json.dumps(data)) + except ValidationError as e: + msg = f"failed to validate databag: {databag}" + logger.debug(msg, exc_info=True) + raise DataValidationError(msg) from e + + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + """Write the contents of this model to Juju databag. + + Args: + databag: The databag to write to. + clear: Whether to clear the databag before writing. + + Returns: + MutableMapping: The databag. + """ + if clear and databag: + databag.clear() + + if databag is None: + databag = {} + nest_under = self.model_config.get("_NEST_UNDER") + if nest_under: + databag[nest_under] = self.model_dump_json( + by_alias=True, + # skip keys whose values are default + exclude_defaults=True, + ) + return databag + + dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=True) + databag.update({k: json.dumps(v) for k, v in dct.items()}) + return databag + + +class ProviderApplicationData(DatabagModel): + """App databag model.""" + + certificates: Set[str] = Field( + description="The set of certificates that will be transferred to a requirer", + default=set(), + ) + + +class CertificateTransferProvides(Object): + """Certificate Transfer provider class to be instantiated by charms sending certificates.""" + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.charm = charm + self.relationship_name = relationship_name + + def add_certificates(self, certificates: Set[str], relation_id: Optional[int] = None) -> None: + """Add certificates from a set to relation data. + + Adds certificate to all relations if relation_id is not provided. + + Args: + certificates (Set[str]): A set of certificate strings in PEM format + relation_id (int): Juju relation ID + + Returns: + None + """ + if not self.charm.unit.is_leader(): + logger.error("Only the leader unit can add certificates to this relation") + return + relations = self._get_relevant_relations(relation_id) + if not relations: + logger.error( + "At least 1 matching relation ID not found with the relation name '%s'", + self.relationship_name, + ) + return + + for relation in relations: + existing_data = self._get_relation_data(relation) + existing_data.update(certificates) + self._set_relation_data(relation, existing_data) + + def remove_certificate( + self, + certificate: str, + relation_id: Optional[int] = None, + ) -> None: + """Remove a given certificate from relation data. + + Removes certificate from all relations if relation_id not given + + Args: + certificate (str): Certificate in PEM format that's in the list + relation_id (int): Relation ID + + Returns: + None + """ + if not self.charm.unit.is_leader(): + logger.error("Only the leader unit can add certificates to this relation") + return + relations = self._get_relevant_relations(relation_id) + if not relations: + logger.error( + "At least 1 matching relation ID not found with the relation name '%s'", + self.relationship_name, + ) + return + + for relation in relations: + existing_data = self._get_relation_data(relation) + existing_data.discard(certificate) + self._set_relation_data(relation, existing_data) + + def _get_relevant_relations(self, relation_id: Optional[int] = None) -> List[Relation]: + """Get the relevant relation if relation_id is given, all relations otherwise.""" + if relation_id is not None: + relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if relation and relation.active: + return [relation] + return [] + + return list(self.model.relations[self.relationship_name]) + + def _set_relation_data(self, relation: Relation, data: Set[str]) -> None: + """Set the given relation data.""" + databag = relation.data[self.model.app] + ProviderApplicationData(certificates=data).dump(databag, False) + + def _get_relation_data(self, relation: Relation) -> Set[str]: + """Get the given relation data.""" + databag = relation.data[self.model.app] + try: + return ProviderApplicationData().load(databag).certificates + except DataValidationError as e: + logger.error( + ( + "Error parsing relation databag: %s. ", + "Make sure not to interact with the databags " + "except using the public methods in the provider library " + "and use version V1.", + ), + e.args, + ) + return set() + + +class CertificatesAvailableEvent(EventBase): + """Charm Event triggered when the set of provided certificates is updated.""" + + def __init__( + self, + handle: Handle, + certificates: Set[str], + relation_id: int, + ): + super().__init__(handle) + self.certificates = certificates + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Return snapshot.""" + return { + "certificates": self.certificates, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificates = snapshot["certificates"] + self.relation_id = snapshot["relation_id"] + + +class CertificatesRemovedEvent(EventBase): + """Charm Event triggered when the set of provided certificates is removed.""" + + def __init__(self, handle: Handle, relation_id: int): + super().__init__(handle) + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Return snapshot.""" + return {"relation_id": self.relation_id} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.relation_id = snapshot["relation_id"] + + +class CertificateTransferRequirerCharmEvents(CharmEvents): + """List of events that the Certificate Transfer requirer charm can leverage.""" + + certificate_set_updated = EventSource(CertificatesAvailableEvent) + certificates_removed = EventSource(CertificatesRemovedEvent) + + +class CertificateTransferRequires(Object): + """Certificate transfer requirer class to be instantiated by charms expecting certificates.""" + + on = CertificateTransferRequirerCharmEvents() # type: ignore + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + ): + """Observe events related to the relation. + + Args: + charm: Charm object + relationship_name: Juju relation name + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + charm.on[relationship_name].relation_broken, self._on_relation_broken + ) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Emit certificate set updated event. + + Args: + event: Juju event + + Returns: + None + """ + remote_unit_relation_data = self.get_all_certificates(event.relation.id) + self.on.certificate_set_updated.emit( + certificates=remote_unit_relation_data, + relation_id=event.relation.id, + ) + + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handle relation broken event. + + Args: + event: Juju event + + Returns: + None + """ + self.on.certificates_removed.emit(relation_id=event.relation.id) + + def get_all_certificates(self, relation_id: Optional[int] = None) -> Set[str]: + """Get transferred certificates. + + If no relation id is given, certificates from all relations will be + provided in a concatenated list. + + Args: + relation_id: The id of the relation to get the certificates from. + """ + relations = self._get_relevant_relations(relation_id) + result = set() + for relation in relations: + data = self._get_relation_data(relation) + result = result.union(data) + return result + + def _get_relation_data(self, relation: Relation) -> Set[str]: + """Get the given relation data.""" + databag = relation.data[relation.app] + try: + return ProviderApplicationData().load(databag).certificates + except DataValidationError as e: + logger.error( + ( + "Error parsing relation databag: %s. ", + "Make sure not to interact with the databags " + "except using the public methods in the provider library " + "and use version V1.", + ), + e.args, + ) + return set() + + def _get_relevant_relations(self, relation_id: Optional[int] = None) -> List[Relation]: + """Get the relevant relation if relation_id is given, all relations otherwise.""" + if relation_id is not None: + if relation := self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ): + return [relation] + return list(self.model.relations[self.relationship_name]) diff --git a/src/charm.py b/src/charm.py index 8df7621..4d298f3 100755 --- a/src/charm.py +++ b/src/charm.py @@ -13,6 +13,9 @@ import ops import yaml +from charms.certificate_transfer_interface.v1.certificate_transfer import ( + CertificateTransferProvides, +) from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.loki_k8s.v1.loki_push_api import LogForwarder from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider @@ -51,6 +54,7 @@ CERTIFICATE_COMMON_NAME = "Notary Self Signed Certificate" SELF_SIGNED_CA_COMMON_NAME = "Notary Self Signed Root CA" NOTARY_LOGIN_SECRET_LABEL = "Notary Login Details" +SEND_CA_CERT_RELATION_NAME = "send-ca-cert" @dataclass @@ -86,6 +90,7 @@ def __init__(self, framework: ops.Framework): self.tls = TLSCertificatesProvidesV4( self, relationship_name=CERTIFICATE_PROVIDER_RELATION_NAME ) + self.certificate_transfer = CertificateTransferProvides(self, SEND_CA_CERT_RELATION_NAME) self.dashboard = GrafanaDashboardProvider(self, relation_name=GRAFANA_RELATION_NAME) self.logs = LogForwarder(charm=self, relation_name=LOGGING_RELATION_NAME) self.ingress = IngressPerAppRequirer( @@ -126,6 +131,7 @@ def __init__(self, framework: ops.Framework): self.on["certificates"].relation_departed, self.on["access-certificates"].relation_changed, self.on["access-certificates"].relation_departed, + self.on[SEND_CA_CERT_RELATION_NAME].relation_joined, self.on.config_storage_attached, self.on.database_storage_attached, self.on.config_changed, @@ -146,6 +152,7 @@ def configure(self, event: ops.EventBase): self._configure_access_certificates() self._configure_charm_authorization() self._configure_certificate_requirers() + self._send_ca_cert() def _on_collect_status(self, event: ops.CollectStatusEvent): if not self.unit.is_leader(): @@ -290,6 +297,18 @@ def _configure_certificate_requirers(self): ) ) + def _send_ca_cert(self): + """Send the existing CA cert in the workload to all relations.""" + with self.container.pull( + f"{WORKLOAD_CONFIG_PATH}/{CONFIG_MOUNT}/ca.pem", + ) as ca_cert_file: + if ca_cert := ca_cert_file.read().strip(): + for relation in self.model.relations.get(SEND_CA_CERT_RELATION_NAME, []): + self.certificate_transfer.add_certificates( + certificates={ca_cert}, relation_id=relation.id + ) + logger.info("Sent CA certificate to relation %s", relation.id) + ## Properties ## @property def _pebble_layer(self) -> ops.pebble.LayerDict: diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index b44b9b4..8a956a7 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -41,7 +41,7 @@ class TestCharm: def context(self): yield Context(NotaryCharm) - def example_cert_and_key(self) -> tuple[Certificate, PrivateKey]: + def example_certs_and_key(self) -> tuple[Certificate, Certificate, PrivateKey]: private_key = generate_private_key() csr = generate_csr( private_key=private_key, @@ -59,7 +59,7 @@ def example_cert_and_key(self) -> tuple[Certificate, PrivateKey]: ca_private_key=ca_private_key, validity=365, ) - return certificate, private_key + return certificate, ca_certificate, private_key # Configure tests def test_given_only_config_storage_container_cant_connect_network_not_available_notary_not_running_when_configure_then_no_error_raised( @@ -2948,7 +2948,7 @@ def test_given_notary_available_and_initialized_when_collect_status_then_status_ leader=True, ) - certificate, _ = self.example_cert_and_key() + certificate, _, _ = self.example_certs_and_key() with open(tmpdir + "/certificate.pem", "w") as f: f.write(str(certificate)) @@ -3457,9 +3457,11 @@ def test_given_access_relation_created_when_configure_then_certificate_not_repla relations=[Relation(id=1, endpoint=TLS_ACCESS_RELATION_NAME)], leader=True, ) - certificate, _ = self.example_cert_and_key() + certificate, ca, _ = self.example_certs_and_key() with open(tmpdir + "/certificate.pem", "w") as f: f.write(str(certificate)) + with open(tmpdir + "/ca.pem", "w") as f: + f.write(str(ca)) mock_assigned_certificates.return_value = (None, None) with patch( "notary.Notary.__new__", @@ -3511,8 +3513,8 @@ def test_given_new_certificate_available_when_configure_then_certificate_replace relations=[Relation(id=1, endpoint=TLS_ACCESS_RELATION_NAME)], leader=True, ) - existing_certificate, _ = self.example_cert_and_key() - certificate, pk = self.example_cert_and_key() + existing_certificate, _, _ = self.example_certs_and_key() + certificate, _, pk = self.example_certs_and_key() provider_certificate_mock = Mock() provider_certificate_mock.certificate = certificate.raw with open(tmpdir + "/certificate.pem", "w") as f: @@ -3567,11 +3569,13 @@ def test_given_new_certificate_available_and_new_cert_already_saved_when_configu relations=[Relation(id=1, endpoint=TLS_ACCESS_RELATION_NAME)], leader=True, ) - certificate, pk = self.example_cert_and_key() + certificate, ca, pk = self.example_certs_and_key() provider_certificate_mock = Mock() provider_certificate_mock.certificate = certificate.raw with open(tmpdir + "/certificate.pem", "w") as f: f.write(str(certificate)) + with open(tmpdir + "/ca.pem", "w") as f: + f.write(str(ca)) mock_assigned_certificates.return_value = (provider_certificate_mock, pk) with patch( "notary.Notary.__new__", From 2dd648a87de9de5aef7fd1ed8abe9284811cc1c2 Mon Sep 17 00:00:00 2001 From: yazansalti Date: Fri, 27 Sep 2024 10:59:17 +0400 Subject: [PATCH 04/10] Adds a unit test case --- src/charm.py | 2 +- tests/unit/test_charm.py | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/charm.py b/src/charm.py index 4d298f3..21c15be 100755 --- a/src/charm.py +++ b/src/charm.py @@ -298,7 +298,7 @@ def _configure_certificate_requirers(self): ) def _send_ca_cert(self): - """Send the existing CA cert in the workload to all relations.""" + """Send the CA certificate in the workload to all requirers of the certificate-transfer interface.""" with self.container.pull( f"{WORKLOAD_CONFIG_PATH}/{CONFIG_MOUNT}/ca.pem", ) as ca_cert_file: diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 8a956a7..7eda740 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -12,6 +12,7 @@ from charm import ( CERTIFICATE_PROVIDER_RELATION_NAME, NOTARY_LOGIN_SECRET_LABEL, + SEND_CA_CERT_RELATION_NAME, TLS_ACCESS_RELATION_NAME, NotaryCharm, ) @@ -34,6 +35,7 @@ SELF_SIGNED_CA_COMMON_NAME = "Notary Self Signed Root CA" TLS_LIB_PATH = "charms.tls_certificates_interface.v4.tls_certificates" +CERT_TRANSFER_LIB_PATH = "charms.certificate_transfer_interface.v1.certificate_transfer" class TestCharm: @@ -3592,3 +3594,57 @@ def test_given_new_certificate_available_and_new_cert_already_saved_when_configu with open(tmpdir + "/certificate.pem") as f: saved_cert = f.read() assert saved_cert == str(certificate) + + @patch(f"{CERT_TRANSFER_LIB_PATH}.CertificateTransferProvides.add_certificates") + def test_given_send_ca_requirer_when_configure_then_ca_cert_sent( + self, mock_add_certificates, context, tmpdir + ): + config_mount = Mount(location="/etc/notary/config", source=tmpdir) + state = State( + storages={Storage(name="config"), Storage(name="database")}, + containers=[ + Container( + name="notary", + can_connect=True, + mounts={"config": config_mount}, + layers={ + "notary": Layer( + { + "summary": "notary layer", + "description": "pebble config layer for notary", + "services": { + "notary": { + "override": "replace", + "summary": "notary", + "command": "notary -config /etc/notary/config/config.yaml", + "startup": "enabled", + } + }, + } + ) + }, + ) + ], + relations=[ + Relation(id=1, endpoint=SEND_CA_CERT_RELATION_NAME), + ], + leader=True, + ) + certificate, ca, pk = self.example_certs_and_key() + with open(tmpdir + "/certificate.pem", "w") as f: + f.write(str(certificate)) + with open(tmpdir + "/ca.pem", "w") as f: + f.write(str(ca)) + with patch( + "notary.Notary.__new__", + return_value=Mock( + **{ + "is_api_available.return_value": True, + "is_initialized.return_value": True, + "login.return_value": "example-token", + "token_is_valid.return_value": True, + }, + ), + ): + context.run(context.on.update_status(), state) + mock_add_certificates.assert_called_once_with(certificates={str(ca)}, relation_id=1) From d12992b17157f9a92274262669ea4c8f79556e4a Mon Sep 17 00:00:00 2001 From: yazansalti Date: Fri, 27 Sep 2024 11:01:06 +0400 Subject: [PATCH 05/10] Fix integration test --- tests/integration/test_charm.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 486efd5..307a25e 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -197,6 +197,10 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s await ops_test.model.deploy( TRAEIK_K8S_APPLICATION_NAME, application_name=TRAEIK_K8S_APPLICATION_NAME, trust=True ) + await ops_test.model.integrate( + relation1=f"{APP_NAME}:ingress", + relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:ingress", + ) await ops_test.model.wait_for_idle( apps=[APP_NAME, TRAEIK_K8S_APPLICATION_NAME], status="active", From 1f364debd9f403e77e5a6b3fafc130cdc79919ea Mon Sep 17 00:00:00 2001 From: yazansalti Date: Fri, 27 Sep 2024 11:11:05 +0400 Subject: [PATCH 06/10] Improves integration tests --- tests/integration/test_charm.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 307a25e..17dfcea 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -33,6 +33,8 @@ TRAEIK_K8S_APPLICATION_NAME = "traefik-k8s" TLS_PROVIDER_APPLICATION_NAME = "self-signed-certificates" TLS_REQUIRER_APPLICATION_NAME = "tls-certificates-requirer" +# TODO: Set correct revision when https://github.com/canonical/traefik-k8s-operator/pull/407 is merged +TRAEIK_K8S_REVISION = 1 @pytest.mark.abort_on_fail @@ -195,18 +197,29 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s ops_test: OpsTest, ): await ops_test.model.deploy( - TRAEIK_K8S_APPLICATION_NAME, application_name=TRAEIK_K8S_APPLICATION_NAME, trust=True + TRAEIK_K8S_APPLICATION_NAME, + application_name=TRAEIK_K8S_APPLICATION_NAME, + trust=True, + channel="stable", + revision=TRAEIK_K8S_REVISION, ) await ops_test.model.integrate( relation1=f"{APP_NAME}:ingress", relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:ingress", ) + await ops_test.model.integrate( + relation1=f"{APP_NAME}:send-ca-cert", + relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:receive-ca-cert-v1", + ) await ops_test.model.wait_for_idle( apps=[APP_NAME, TRAEIK_K8S_APPLICATION_NAME], status="active", timeout=1000, raise_on_error=True, ) + endpoint = await get_external_notary_endpoint(ops_test) + client = Notary(url=endpoint) + assert client.is_api_available() async def get_notary_endpoint(ops_test: OpsTest) -> str: @@ -215,6 +228,12 @@ async def get_notary_endpoint(ops_test: OpsTest) -> str: notary_ip = status.applications[APP_NAME].units[f"{APP_NAME}/0"].address return f"https://{notary_ip}:2111" +async def get_external_notary_endpoint(ops_test: OpsTest) -> str: + assert ops_test.model + status = await ops_test.model.get_status() + traefik_proxied_endpoints = await run_show_traefik_proxied_endpoints_action(ops_test) + return json.loads(traefik_proxied_endpoints).get(APP_NAME, "").get("url", "") + async def get_notary_credentials(ops_test: OpsTest) -> dict[str, str]: assert ops_test.model @@ -242,6 +261,12 @@ async def run_get_certificate_action(ops_test: OpsTest) -> str: action_output = await ops_test.model.get_action_output(action_uuid=action.entity_id, wait=30) return action_output.get("certificates", "") +async def run_show_traefik_proxied_endpoints_action(ops_test: OpsTest) -> str: + assert ops_test.model + traefik_k8s_unit = ops_test.model.units[f"{TRAEIK_K8S_APPLICATION_NAME}/0"] + action = await traefik_k8s_unit.run_action(action_name="show-proxied-endpoints") # type: ignore + action_output = await ops_test.model.get_action_output(action_uuid=action.entity_id, wait=30) + return action_output.get("proxied-endpoints", "") async def get_file_from_notary(ops_test: OpsTest, file_name: str) -> str: notary_unit = ops_test.model.units[f"{APP_NAME}/0"] # type: ignore From 364e4ec020244513a24220a658abb11c4ed64b0b Mon Sep 17 00:00:00 2001 From: yazansalti Date: Thu, 10 Oct 2024 10:00:34 +0400 Subject: [PATCH 07/10] Workaround in integration tests with Traefik --- src/charm.py | 1 + tests/integration/test_charm.py | 20 ++++++++++++-------- tests/unit/test_charm.py | 3 ++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/charm.py b/src/charm.py index 3c4512a..bbd6398 100755 --- a/src/charm.py +++ b/src/charm.py @@ -308,6 +308,7 @@ def _send_ca_cert(self): certificates={ca_cert}, relation_id=relation.id ) logger.info("Sent CA certificate to relation %s", relation.id) + def _configure_juju_workload_version(self): """Set the Juju workload version to the Notary version.""" if not self.unit.is_leader(): diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index fcaa92c..630115d 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -33,8 +33,6 @@ TRAEIK_K8S_APPLICATION_NAME = "traefik-k8s" TLS_PROVIDER_APPLICATION_NAME = "self-signed-certificates" TLS_REQUIRER_APPLICATION_NAME = "tls-certificates-requirer" -# TODO: Set correct revision when https://github.com/canonical/traefik-k8s-operator/pull/407 is merged -TRAEIK_K8S_REVISION = 1 @pytest.mark.abort_on_fail @@ -199,15 +197,19 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s application_name=TRAEIK_K8S_APPLICATION_NAME, trust=True, channel="stable", - revision=TRAEIK_K8S_REVISION, ) + # TODO (Tracked in TLSENG-475): This is a workaround so Traefik has the same CA as Notary + # This should be removed and certificate transfer should be used instead + # Notary k8s implements V1 of the certificate transfer interface, + # And the following PR is needed to get Traefik to use it too: + # https://github.com/canonical/traefik-k8s-operator/issues/407 await ops_test.model.integrate( - relation1=f"{APP_NAME}:ingress", - relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:ingress", + relation1=f"{TLS_PROVIDER_APPLICATION_NAME}:certificates", + relation2=f"{TRAEIK_K8S_APPLICATION_NAME}", ) await ops_test.model.integrate( - relation1=f"{APP_NAME}:send-ca-cert", - relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:receive-ca-cert-v1", + relation1=f"{APP_NAME}:ingress", + relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:ingress", ) await ops_test.model.wait_for_idle( apps=[APP_NAME, TRAEIK_K8S_APPLICATION_NAME], @@ -226,9 +228,9 @@ async def get_notary_endpoint(ops_test: OpsTest) -> str: notary_ip = status.applications[APP_NAME].units[f"{APP_NAME}/0"].address return f"https://{notary_ip}:2111" + async def get_external_notary_endpoint(ops_test: OpsTest) -> str: assert ops_test.model - status = await ops_test.model.get_status() traefik_proxied_endpoints = await run_show_traefik_proxied_endpoints_action(ops_test) return json.loads(traefik_proxied_endpoints).get(APP_NAME, "").get("url", "") @@ -259,6 +261,7 @@ async def run_get_certificate_action(ops_test: OpsTest) -> str: action_output = await ops_test.model.get_action_output(action_uuid=action.entity_id, wait=30) return action_output.get("certificates", "") + async def run_show_traefik_proxied_endpoints_action(ops_test: OpsTest) -> str: assert ops_test.model traefik_k8s_unit = ops_test.model.units[f"{TRAEIK_K8S_APPLICATION_NAME}/0"] @@ -266,6 +269,7 @@ async def run_show_traefik_proxied_endpoints_action(ops_test: OpsTest) -> str: action_output = await ops_test.model.get_action_output(action_uuid=action.entity_id, wait=30) return action_output.get("proxied-endpoints", "") + async def get_file_from_notary(ops_test: OpsTest, file_name: str) -> str: notary_unit = ops_test.model.units[f"{APP_NAME}/0"] # type: ignore action = await notary_unit.run(f"cat /var/lib/juju/storage/config/0/{file_name}") # type: ignore diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index d704a13..e9f50c2 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -3690,8 +3690,9 @@ def test_given_send_ca_requirer_when_configure_then_ca_cert_sent( **{ "is_api_available.return_value": True, "is_initialized.return_value": True, - "login.return_value": "example-token", + "login.return_value": LoginResponse(token="example-token"), "token_is_valid.return_value": True, + "get_version.return_value": "1.2.3", }, ), ): From 673d83f3e59db0fba96596cfded0ce7492f91d26 Mon Sep 17 00:00:00 2001 From: yazansalti Date: Thu, 10 Oct 2024 14:42:13 +0400 Subject: [PATCH 08/10] Fix integration tests --- tests/integration/test_charm.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 630115d..9f632f8 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -64,6 +64,12 @@ async def test_build_and_deploy(ops_test: OpsTest, request: pytest.FixtureReques await ops_test.model.deploy( "loki-k8s", application_name=LOKI_APPLICATION_NAME, trust=True, channel="stable" ) + await ops_test.model.deploy( + TRAEIK_K8S_APPLICATION_NAME, + application_name=TRAEIK_K8S_APPLICATION_NAME, + trust=True, + channel="stable", + ) await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) @@ -192,12 +198,6 @@ async def test_given_loki_and_prometheus_related_to_notary_all_charm_statuses_ac async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_statuses_active( ops_test: OpsTest, ): - await ops_test.model.deploy( - TRAEIK_K8S_APPLICATION_NAME, - application_name=TRAEIK_K8S_APPLICATION_NAME, - trust=True, - channel="stable", - ) # TODO (Tracked in TLSENG-475): This is a workaround so Traefik has the same CA as Notary # This should be removed and certificate transfer should be used instead # Notary k8s implements V1 of the certificate transfer interface, @@ -218,8 +218,12 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s raise_on_error=True, ) endpoint = await get_external_notary_endpoint(ops_test) + assert ops_test.model + admin_credentials = await get_notary_credentials(ops_test) + token = admin_credentials.get("token") + assert token client = Notary(url=endpoint) - assert client.is_api_available() + assert client.token_is_valid(token) async def get_notary_endpoint(ops_test: OpsTest) -> str: From 0cf1fdee87c96b152685d221b8d6a0c922a4539b Mon Sep 17 00:00:00 2001 From: yazansalti Date: Fri, 11 Oct 2024 11:18:43 +0400 Subject: [PATCH 09/10] Add access-certificates integration --- tests/integration/test_charm.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 9f632f8..877b46e 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -203,10 +203,15 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s # Notary k8s implements V1 of the certificate transfer interface, # And the following PR is needed to get Traefik to use it too: # https://github.com/canonical/traefik-k8s-operator/issues/407 + assert ops_test.model await ops_test.model.integrate( relation1=f"{TLS_PROVIDER_APPLICATION_NAME}:certificates", relation2=f"{TRAEIK_K8S_APPLICATION_NAME}", ) + await ops_test.model.integrate( + relation1=f"{TLS_PROVIDER_APPLICATION_NAME}:certificates", + relation2=f"{APP_NAME}:access-certificates", + ) await ops_test.model.integrate( relation1=f"{APP_NAME}:ingress", relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:ingress", @@ -218,12 +223,8 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s raise_on_error=True, ) endpoint = await get_external_notary_endpoint(ops_test) - assert ops_test.model - admin_credentials = await get_notary_credentials(ops_test) - token = admin_credentials.get("token") - assert token client = Notary(url=endpoint) - assert client.token_is_valid(token) + assert client.is_api_available() async def get_notary_endpoint(ops_test: OpsTest) -> str: From 2aba2214719153908cfaddbc591856c044682449 Mon Sep 17 00:00:00 2001 From: yazansalti Date: Mon, 14 Oct 2024 10:28:18 +0400 Subject: [PATCH 10/10] Fix typo --- tests/integration/test_charm.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 877b46e..1e993ef 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -30,7 +30,7 @@ LOKI_APPLICATION_NAME = "loki-k8s" PROMETHEUS_APPLICATION_NAME = "prometheus-k8s" -TRAEIK_K8S_APPLICATION_NAME = "traefik-k8s" +TRAEFIK_K8S_APPLICATION_NAME = "traefik-k8s" TLS_PROVIDER_APPLICATION_NAME = "self-signed-certificates" TLS_REQUIRER_APPLICATION_NAME = "tls-certificates-requirer" @@ -65,8 +65,8 @@ async def test_build_and_deploy(ops_test: OpsTest, request: pytest.FixtureReques "loki-k8s", application_name=LOKI_APPLICATION_NAME, trust=True, channel="stable" ) await ops_test.model.deploy( - TRAEIK_K8S_APPLICATION_NAME, - application_name=TRAEIK_K8S_APPLICATION_NAME, + TRAEFIK_K8S_APPLICATION_NAME, + application_name=TRAEFIK_K8S_APPLICATION_NAME, trust=True, channel="stable", ) @@ -206,7 +206,7 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s assert ops_test.model await ops_test.model.integrate( relation1=f"{TLS_PROVIDER_APPLICATION_NAME}:certificates", - relation2=f"{TRAEIK_K8S_APPLICATION_NAME}", + relation2=f"{TRAEFIK_K8S_APPLICATION_NAME}", ) await ops_test.model.integrate( relation1=f"{TLS_PROVIDER_APPLICATION_NAME}:certificates", @@ -214,10 +214,10 @@ async def test_given_application_deployed_when_related_to_traefik_k8s_then_all_s ) await ops_test.model.integrate( relation1=f"{APP_NAME}:ingress", - relation2=f"{TRAEIK_K8S_APPLICATION_NAME}:ingress", + relation2=f"{TRAEFIK_K8S_APPLICATION_NAME}:ingress", ) await ops_test.model.wait_for_idle( - apps=[APP_NAME, TRAEIK_K8S_APPLICATION_NAME], + apps=[APP_NAME, TRAEFIK_K8S_APPLICATION_NAME], status="active", timeout=1000, raise_on_error=True, @@ -269,7 +269,7 @@ async def run_get_certificate_action(ops_test: OpsTest) -> str: async def run_show_traefik_proxied_endpoints_action(ops_test: OpsTest) -> str: assert ops_test.model - traefik_k8s_unit = ops_test.model.units[f"{TRAEIK_K8S_APPLICATION_NAME}/0"] + traefik_k8s_unit = ops_test.model.units[f"{TRAEFIK_K8S_APPLICATION_NAME}/0"] action = await traefik_k8s_unit.run_action(action_name="show-proxied-endpoints") # type: ignore action_output = await ops_test.model.get_action_output(action_uuid=action.entity_id, wait=30) return action_output.get("proxied-endpoints", "")