Nginx Ingress Integrator

  • By Canonical IS DevOps
Channel Revision Published Runs on
latest/stable 84 22 Feb 2024
Ubuntu 20.04
latest/edge 93 23 Apr 2024
Ubuntu 20.04
v2/edge 84 09 Jan 2024
Ubuntu 20.04
juju deploy nginx-ingress-integrator
Show information

Platform:

Ubuntu
20.04

charms.nginx_ingress_integrator.v0.ingress

# Copyright 2023 Canonical Ltd.
# Licensed under the Apache2.0. See LICENSE file in charm source for details.
"""Library for the ingress relation.

This library contains the Requires and Provides classes for handling
the ingress interface.

Import `IngressRequires` in your charm, with two required options:
- "self" (the charm itself)
- config_dict

`config_dict` accepts the following keys:
- additional-hostnames
- backend-protocol
- limit-rps
- limit-whitelist
- max-body-size
- owasp-modsecurity-crs
- owasp-modsecurity-custom-rules
- path-routes
- retry-errors
- rewrite-enabled
- rewrite-target
- service-hostname (required)
- service-name (required)
- service-namespace
- service-port (required)
- session-cookie-max-age
- tls-secret-name

See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions
of each, along with the required type.

As an example, add the following to `src/charm.py`:
```
from charms.nginx_ingress_integrator.v0.ingress import IngressRequires

# In your charm's `__init__` method (assuming your app is listening on port 8080).
self.ingress = IngressRequires(self, {
        "service-hostname": self.app.name,
        "service-name": self.app.name,
        "service-port": 8080,
    }
)
```
And then add the following to `metadata.yaml`:
```
requires:
  ingress:
    interface: ingress
```
You _must_ register the IngressRequires class as part of the `__init__` method
rather than, for instance, a config-changed event handler, for the relation
changed event to be properly handled.

In the example above we're setting `service-hostname` (which translates to the
external hostname for the application when related to nginx-ingress-integrator)
to `self.app.name` here. This ensures by default the charm will be available on
the name of the deployed juju application, but can be overridden in a
production deployment by setting `service-hostname` on the
nginx-ingress-integrator charm. For example:
```bash
juju deploy nginx-ingress-integrator
juju deploy my-charm
juju relate nginx-ingress-integrator my-charm:ingress
# The service is now reachable on the ingress IP(s) of your k8s cluster at
# 'http://my-charm'.
juju config nginx-ingress-integrator service-hostname='my-charm.example.com'
# The service is now reachable on the ingress IP(s) of your k8s cluster at
# 'http://my-charm.example.com'.
"""

import copy
import logging
from typing import Dict

from ops.charm import CharmBase, CharmEvents, RelationBrokenEvent, RelationChangedEvent
from ops.framework import EventBase, EventSource, Object
from ops.model import BlockedStatus

INGRESS_RELATION_NAME = "ingress"
INGRESS_PROXY_RELATION_NAME = "ingress-proxy"

# The unique Charmhub library identifier, never change it
LIBID = "db0af4367506491c91663468fb5caa4c"

# Increment this major API version when introducing breaking changes
LIBAPI = 0

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 17

LOGGER = logging.getLogger(__name__)

REQUIRED_INGRESS_RELATION_FIELDS = {"service-hostname", "service-name", "service-port"}

OPTIONAL_INGRESS_RELATION_FIELDS = {
    "additional-hostnames",
    "backend-protocol",
    "limit-rps",
    "limit-whitelist",
    "max-body-size",
    "owasp-modsecurity-crs",
    "owasp-modsecurity-custom-rules",
    "path-routes",
    "retry-errors",
    "rewrite-target",
    "rewrite-enabled",
    "service-namespace",
    "session-cookie-max-age",
    "tls-secret-name",
}

RELATION_INTERFACES_MAPPINGS = {
    "service-hostname": "host",
    "service-name": "name",
    "service-namespace": "model",
    "service-port": "port",
}
RELATION_INTERFACES_MAPPINGS_VALUES = set(RELATION_INTERFACES_MAPPINGS.values())


class IngressAvailableEvent(EventBase):
    """IngressAvailableEvent custom event.

    This event indicates the Ingress provider is available.
    """


class IngressProxyAvailableEvent(EventBase):
    """IngressProxyAvailableEvent custom event.

    This event indicates the IngressProxy provider is available.
    """


class IngressBrokenEvent(RelationBrokenEvent):
    """IngressBrokenEvent custom event.

    This event indicates the Ingress provider is broken.
    """


class IngressCharmEvents(CharmEvents):
    """Custom charm events.

    Attrs:
        ingress_available: Event to indicate that Ingress is available.
        ingress_proxy_available: Event to indicate that IngressProxy is available.
        ingress_broken: Event to indicate that Ingress is broken.
    """

    ingress_available = EventSource(IngressAvailableEvent)
    ingress_proxy_available = EventSource(IngressProxyAvailableEvent)
    ingress_broken = EventSource(IngressBrokenEvent)


class IngressRequires(Object):
    """This class defines the functionality for the 'requires' side of the 'ingress' relation.

    Hook events observed:
        - relation-changed

    Attrs:
        model: Juju model where the charm is deployed.
        config_dict: Contains all the configuration options for Ingress.
    """

    def __init__(self, charm: CharmBase, config_dict: Dict) -> None:
        """Init function for the IngressRequires class.

        Args:
            charm: The charm that requires the ingress relation.
            config_dict: Contains all the configuration options for Ingress.
        """
        super().__init__(charm, INGRESS_RELATION_NAME)

        self.framework.observe(
            charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed
        )

        # Set default values.
        default_relation_fields = {
            "service-namespace": self.model.name,
        }
        config_dict.update(
            (key, value)
            for key, value in default_relation_fields.items()
            if key not in config_dict or not config_dict[key]
        )

        self.config_dict = self._convert_to_relation_interface(config_dict)

    @staticmethod
    def _convert_to_relation_interface(config_dict: Dict) -> Dict:
        """Create a new relation dict that conforms with charm-relation-interfaces.

        Args:
            config_dict: Ingress configuration that doesn't conform with charm-relation-interfaces.

        Returns:
            The Ingress configuration conforming with charm-relation-interfaces.
        """
        config_dict = copy.copy(config_dict)
        config_dict.update(
            (key, config_dict[old_key])
            for old_key, key in RELATION_INTERFACES_MAPPINGS.items()
            if old_key in config_dict and config_dict[old_key]
        )
        return config_dict

    def _config_dict_errors(self, config_dict: Dict, update_only: bool = False) -> bool:
        """Check our config dict for errors.

        Args:
            config_dict: Contains all the configuration options for Ingress.
            update_only: If the charm needs to update only existing keys.

        Returns:
            If we need to update the config dict or not.
        """
        blocked_message = "Error in ingress relation, check `juju debug-log`"
        unknown = [
            config_key
            for config_key in config_dict
            if config_key
            not in REQUIRED_INGRESS_RELATION_FIELDS
            | OPTIONAL_INGRESS_RELATION_FIELDS
            | RELATION_INTERFACES_MAPPINGS_VALUES
        ]
        if unknown:
            LOGGER.error(
                "Ingress relation error, unknown key(s) in config dictionary found: %s",
                ", ".join(unknown),
            )
            self.model.unit.status = BlockedStatus(blocked_message)
            return True
        if not update_only:
            missing = tuple(
                config_key
                for config_key in REQUIRED_INGRESS_RELATION_FIELDS
                if config_key not in self.config_dict
            )
            if missing:
                LOGGER.error(
                    "Ingress relation error, missing required key(s) in config dictionary: %s",
                    ", ".join(sorted(missing)),
                )
                self.model.unit.status = BlockedStatus(blocked_message)
                return True
        return False

    def _on_relation_changed(self, event: RelationChangedEvent) -> None:
        """Handle the relation-changed event.

        Args:
            event: Event triggering the relation-changed hook for the relation.
        """
        # `self.unit` isn't available here, so use `self.model.unit`.
        if self.model.unit.is_leader():
            if self._config_dict_errors(config_dict=self.config_dict):
                return
            event.relation.data[self.model.app].update(
                (key, str(self.config_dict[key])) for key in self.config_dict
            )

    def update_config(self, config_dict: Dict) -> None:
        """Allow for updates to relation.

        Args:
            config_dict: Contains all the configuration options for Ingress.

        Attrs:
            config_dict: Contains all the configuration options for Ingress.
        """
        if self.model.unit.is_leader():
            self.config_dict = self._convert_to_relation_interface(config_dict)
            if self._config_dict_errors(self.config_dict, update_only=True):
                return
            relation = self.model.get_relation(INGRESS_RELATION_NAME)
            if relation:
                for key in self.config_dict:
                    relation.data[self.model.app][key] = str(self.config_dict[key])


class IngressBaseProvides(Object):
    """Parent class for IngressProvides and IngressProxyProvides.

    Attrs:
        model: Juju model where the charm is deployed.
    """

    def __init__(self, charm: CharmBase, relation_name: str) -> None:
        """Init function for the IngressProxyProvides class.

        Args:
            charm: The charm that provides the ingress-proxy relation.
            relation_name: The name of the relation.
        """
        super().__init__(charm, relation_name)
        self.charm = charm

    def _on_relation_changed(self, event: RelationChangedEvent) -> None:
        """Handle a change to the ingress/ingress-proxy relation.

        Confirm we have the fields we expect to receive.

        Args:
            event: Event triggering the relation-changed hook for the relation.
        """
        # `self.unit` isn't available here, so use `self.model.unit`.
        if not self.model.unit.is_leader():
            return

        relation_name = event.relation.name

        assert event.app is not None  # nosec
        if not event.relation.data[event.app]:
            LOGGER.info(
                "%s hasn't finished configuring, waiting until relation is changed again.",
                relation_name,
            )
            return

        ingress_data = {
            field: event.relation.data[event.app].get(field)
            for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
        }

        missing_fields = sorted(
            field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None
        )

        if missing_fields:
            LOGGER.warning(
                "Missing required data fields for %s relation: %s",
                relation_name,
                ", ".join(missing_fields),
            )
            self.model.unit.status = BlockedStatus(
                f"Missing fields for {relation_name}: {', '.join(missing_fields)}"
            )

        if relation_name == INGRESS_RELATION_NAME:
            # Conform to charm-relation-interfaces.
            if "name" in ingress_data and "port" in ingress_data:
                name = ingress_data["name"]
                port = ingress_data["port"]
            else:
                name = ingress_data["service-name"]
                port = ingress_data["service-port"]
            event.relation.data[self.model.app]["url"] = f"http://{name}:{port}/"

            # Create an event that our charm can use to decide it's okay to
            # configure the ingress.
            self.charm.on.ingress_available.emit()
        elif relation_name == INGRESS_PROXY_RELATION_NAME:
            self.charm.on.ingress_proxy_available.emit()


class IngressProvides(IngressBaseProvides):
    """Class containing the functionality for the 'provides' side of the 'ingress' relation.

    Hook events observed:
        - relation-changed
    """

    def __init__(self, charm: CharmBase) -> None:
        """Init function for the IngressProvides class.

        Args:
            charm: The charm that provides the ingress relation.
        """
        super().__init__(charm, INGRESS_RELATION_NAME)
        # Observe the relation-changed hook event and bind
        # self.on_relation_changed() to handle the event.
        self.framework.observe(
            charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed
        )
        self.framework.observe(
            charm.on[INGRESS_RELATION_NAME].relation_broken, self._on_relation_broken
        )

    def _on_relation_broken(self, event: RelationBrokenEvent) -> None:
        """Handle a relation-broken event in the ingress relation.

        Args:
            event: Event triggering the relation-broken hook for the relation.
        """
        if not self.model.unit.is_leader():
            return

        # Create an event that our charm can use to remove the ingress resource.
        self.charm.on.ingress_broken.emit(event.relation)


class IngressProxyProvides(IngressBaseProvides):
    """Class containing the functionality for the 'provides' side of the 'ingress-proxy' relation.

    Hook events observed:
        - relation-changed
    """

    def __init__(self, charm: CharmBase) -> None:
        """Init function for the IngressProxyProvides class.

        Args:
            charm: The charm that provides the ingress-proxy relation.
        """
        super().__init__(charm, INGRESS_PROXY_RELATION_NAME)
        # Observe the relation-changed hook event and bind
        # self.on_relation_changed() to handle the event.
        self.framework.observe(
            charm.on[INGRESS_PROXY_RELATION_NAME].relation_changed, self._on_relation_changed
        )