Migrate traefik ingress to v2 + bind charm rename

Current ingress relation only routes unit to requirer's leader.
Ingress V2 fixes that issue.

bind9-k8s has been renamed to designate-bind-k8s, update the libraries
(and follow up).

Change-Id: I692acfde39e7f545bb57e8a160fe4f6162c2b41e
This commit is contained in:
Guillaume Boutry 2023-09-26 17:31:02 +02:00
parent 4e415a5e26
commit 8fc134fcc8
10 changed files with 442 additions and 224 deletions

View File

@ -7,5 +7,5 @@
charm_build_name: designate-k8s charm_build_name: designate-k8s
juju_channel: 3.1/stable juju_channel: 3.1/stable
juju_classic_mode: false juju_classic_mode: false
microk8s_channel: 1.26-strict/stable microk8s_channel: 1.28-strict/stable
microk8s_classic_mode: false microk8s_classic_mode: false

View File

@ -26,5 +26,6 @@ parts:
charm-binary-python-packages: charm-binary-python-packages:
- cryptography - cryptography
- jsonschema - jsonschema
- pydantic<2.0
- jinja2 - jinja2
- git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam - git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam

View File

@ -4,6 +4,6 @@ echo "INFO: Fetching libs from charmhub."
charmcraft fetch-lib charms.data_platform_libs.v0.database_requires charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
charmcraft fetch-lib charms.keystone_k8s.v1.identity_service charmcraft fetch-lib charms.keystone_k8s.v1.identity_service
charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq
charmcraft fetch-lib charms.traefik_k8s.v1.ingress charmcraft fetch-lib charms.traefik_k8s.v2.ingress
charmcraft fetch-lib charms.bind9_k8s.v0.bind_rndc charmcraft fetch-lib charms.designate_bind_k8s.v0.bind_rndc

View File

@ -13,7 +13,7 @@ Two events are also available to respond to:
- goneaway - goneaway
A basic example showing the usage of this relation follows: A basic example showing the usage of this relation follows:
``` ```
from charms.bind9_k8s.v0.bind_rndc import ( from charms.designate_bind_k8s.v0.bind_rndc import (
BindRndcRequires BindRndcRequires
) )
class BindRndcClientCharm(CharmBase): class BindRndcClientCharm(CharmBase):
@ -31,6 +31,13 @@ class BindRndcClientCharm(CharmBase):
self.bind_rndc.on.goneaway, self.bind_rndc.on.goneaway,
self._on_bind_rndc_goneaway self._on_bind_rndc_goneaway
) )
def _on_bind_rndc_connected(self, event):
'''React to the Bind Rndc Connected event.
This event happens when BindRndc relation is added to the
model.
'''
# Request the rndc key from the Bind Rndc relation.
self.bind_rndc.request_rndc_key("generated nonce")
def _on_bind_rndc_ready(self, event): def _on_bind_rndc_ready(self, event):
'''React to the Bind Rndc Ready event. '''React to the Bind Rndc Ready event.
This event happens when BindRndc relation is added to the This event happens when BindRndc relation is added to the
@ -49,7 +56,6 @@ class BindRndcClientCharm(CharmBase):
import json import json
import logging import logging
import secrets
from typing import ( from typing import (
Any, Any,
Dict, Dict,
@ -63,14 +69,41 @@ import ops
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# The unique Charmhub library identifier, never change it # The unique Charmhub library identifier, never change it
LIBID = "0fb2f64f2a1344feb80044cee22ef3a8" LIBID = "1cb766c981874e7383d17cf54148b3d4"
# Increment this major API version when introducing breaking changes # Increment this major API version when introducing breaking changes
LIBAPI = 0 LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset # Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version # to 0 if you are raising the major API version
LIBPATCH = 3 LIBPATCH = 1
class BindRndcConnectedEvent(ops.EventBase):
"""Bind rndc connected event."""
def __init__(
self,
handle: ops.Handle,
relation_id: int,
relation_name: str,
):
super().__init__(handle)
self.relation_id = relation_id
self.relation_name = relation_name
def snapshot(self) -> dict:
"""Return snapshot data that should be persisted."""
return {
"relation_id": self.relation_id,
"relation_name": self.relation_name,
}
def restore(self, snapshot: Dict[str, Any]):
"""Restore the value state from a given snapshot."""
super().restore(snapshot)
self.relation_id = snapshot["relation_id"]
self.relation_name = snapshot["relation_name"]
class BindRndcReadyEvent(ops.EventBase): class BindRndcReadyEvent(ops.EventBase):
@ -81,22 +114,16 @@ class BindRndcReadyEvent(ops.EventBase):
handle: ops.Handle, handle: ops.Handle,
relation_id: int, relation_id: int,
relation_name: str, relation_name: str,
algorithm: str,
secret: str,
): ):
super().__init__(handle) super().__init__(handle)
self.relation_id = relation_id self.relation_id = relation_id
self.relation_name = relation_name self.relation_name = relation_name
self.algorithm = algorithm
self.secret = secret
def snapshot(self) -> dict: def snapshot(self) -> dict:
"""Return snapshot data that should be persisted.""" """Return snapshot data that should be persisted."""
return { return {
"relation_id": self.relation_id, "relation_id": self.relation_id,
"relation_name": self.relation_name, "relation_name": self.relation_name,
"algorithm": self.algorithm,
"secret": self.secret,
} }
def restore(self, snapshot: Dict[str, Any]): def restore(self, snapshot: Dict[str, Any]):
@ -104,8 +131,6 @@ class BindRndcReadyEvent(ops.EventBase):
super().restore(snapshot) super().restore(snapshot)
self.relation_id = snapshot["relation_id"] self.relation_id = snapshot["relation_id"]
self.relation_name = snapshot["relation_name"] self.relation_name = snapshot["relation_name"]
self.algorithm = snapshot["algorithm"]
self.secret = snapshot["secret"]
class BindRndcGoneAwayEvent(ops.EventBase): class BindRndcGoneAwayEvent(ops.EventBase):
@ -117,7 +142,8 @@ class BindRndcGoneAwayEvent(ops.EventBase):
class BindRndcRequirerEvents(ops.ObjectEvents): class BindRndcRequirerEvents(ops.ObjectEvents):
"""List of events that the BindRndc requires charm can leverage.""" """List of events that the BindRndc requires charm can leverage."""
bind_rndc_ready = ops.EventSource(BindRndcReadyEvent) connected = ops.EventSource(BindRndcConnectedEvent)
ready = ops.EventSource(BindRndcReadyEvent)
goneaway = ops.EventSource(BindRndcGoneAwayEvent) goneaway = ops.EventSource(BindRndcGoneAwayEvent)
@ -125,13 +151,11 @@ class BindRndcRequires(ops.Object):
"""Class to be instantiated by the requiring side of the relation.""" """Class to be instantiated by the requiring side of the relation."""
on = BindRndcRequirerEvents() on = BindRndcRequirerEvents()
_stored = ops.StoredState()
def __init__(self, charm: ops.CharmBase, relation_name: str): def __init__(self, charm: ops.CharmBase, relation_name: str):
super().__init__(charm, relation_name) super().__init__(charm, relation_name)
self.charm = charm self.charm = charm
self.relation_name = relation_name self.relation_name = relation_name
self._stored.set_default(nonce="")
self.framework.observe( self.framework.observe(
self.charm.on[relation_name].relation_joined, self.charm.on[relation_name].relation_joined,
self._on_relation_joined, self._on_relation_joined,
@ -147,24 +171,20 @@ class BindRndcRequires(ops.Object):
def _on_relation_joined(self, event: ops.RelationJoinedEvent): def _on_relation_joined(self, event: ops.RelationJoinedEvent):
"""Handle relation joined event.""" """Handle relation joined event."""
self._request_rndc_key(event.relation) self.on.connected.emit(
event.relation.id,
event.relation.name,
)
def _on_relation_changed(self, event: ops.RelationJoinedEvent): def _on_relation_changed(self, event: ops.RelationJoinedEvent):
"""Handle relation changed event.""" """Handle relation changed event."""
host = self.host(event.relation) host = self.host(event.relation)
rndc_key = self.get_rndc_key(event.relation) rndc_key = self.get_rndc_key(event.relation)
if rndc_key is None:
self._request_rndc_key(event.relation)
return
if host is not None: if all((host, rndc_key)):
algorithm = rndc_key["algorithm"] self.on.ready.emit(
secret = rndc_key["secret"]
self.on.bind_rndc_ready.emit(
event.relation.id, event.relation.id,
event.relation.name, event.relation.name,
algorithm,
secret,
) )
def _on_relation_broken(self, event: ops.RelationBrokenEvent): def _on_relation_broken(self, event: ops.RelationBrokenEvent):
@ -177,33 +197,25 @@ class BindRndcRequires(ops.Object):
return None return None
return relation.data[relation.app].get("host") return relation.data[relation.app].get("host")
def nonce(self) -> str: def nonce(self, relation: ops.Relation) -> Optional[str]:
"""Return nonce from stored state.""" """Return nonce from relation."""
return self._stored.nonce return relation.data[self.charm.unit].get("nonce")
def get_rndc_key(self, relation: ops.Relation) -> Optional[dict]: def get_rndc_key(self, relation: ops.Relation) -> Optional[dict]:
"""Get rndc keys.""" """Get rndc keys."""
if relation.app is None: if relation.app is None:
return None return None
if self._stored.nonce == "": if self.nonce(relation) is None:
logger.debug("No nonce set for unit yet") logger.debug("No nonce set for unit yet")
return None return None
return json.loads( return json.loads(
relation.data[relation.app].get("rndc_keys", "{}") relation.data[relation.app].get("rndc_keys", "{}")
).get(self._stored.nonce) ).get(self.nonce(relation))
def _request_rndc_key(self, relation: ops.Relation): def request_rndc_key(self, relation: ops.Relation, nonce: str):
"""Request rndc key over the relation.""" """Request rndc key over the relation."""
if self._stored.nonce == "": relation.data[self.charm.unit]["nonce"] = nonce
self._stored.nonce = secrets.token_hex(16)
relation.data[self.charm.unit]["nonce"] = self._stored.nonce
def reconcile_rndc_key(self, relation: ops.Relation):
"""Reconcile rndc key over the relation."""
if self._stored.nonce != relation.data[self.charm.unit].get("nonce"):
self._stored.nonce = secrets.token_hex(16)
relation.data[self.charm.unit]["nonce"] = self._stored.nonce
class NewBindClientAttachedEvent(ops.EventBase): class NewBindClientAttachedEvent(ops.EventBase):

View File

@ -1,4 +1,4 @@
# Copyright 2022 Canonical Ltd. # Copyright 2023 Canonical Ltd.
# See LICENSE file for licensing details. # See LICENSE file for licensing details.
r"""# Interface Library for ingress. r"""# Interface Library for ingress.
@ -28,7 +28,7 @@ requires:
Then, to initialise the library: Then, to initialise the library:
```python ```python
from charms.traefik_k8s.v1.ingress import (IngressPerAppRequirer, from charms.traefik_k8s.v2.ingress import (IngressPerAppRequirer,
IngressPerAppReadyEvent, IngressPerAppRevokedEvent) IngressPerAppReadyEvent, IngressPerAppRevokedEvent)
class SomeCharm(CharmBase): class SomeCharm(CharmBase):
@ -50,105 +50,181 @@ class SomeCharm(CharmBase):
def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent):
logger.info("This app no longer has ingress") logger.info("This app no longer has ingress")
""" """
import json
import logging import logging
import socket import socket
import typing import typing
from typing import Any, Dict, Optional, Tuple, Union from dataclasses import dataclass
from typing import (
Any,
Dict,
List,
MutableMapping,
Optional,
Sequence,
Tuple,
)
import yaml import pydantic
from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent
from ops.framework import EventSource, Object, ObjectEvents, StoredState from ops.framework import EventSource, Object, ObjectEvents, StoredState
from ops.model import ModelError, Relation from ops.model import ModelError, Relation, Unit
from pydantic import AnyHttpUrl, BaseModel, Field, validator
# The unique Charmhub library identifier, never change it # The unique Charmhub library identifier, never change it
LIBID = "e6de2a5cd5b34422a204668f3b8f90d2" LIBID = "e6de2a5cd5b34422a204668f3b8f90d2"
# Increment this major API version when introducing breaking changes # Increment this major API version when introducing breaking changes
LIBAPI = 1 LIBAPI = 2
# Increment this PATCH version before using `charmcraft publish-lib` or reset # Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version # to 0 if you are raising the major API version
LIBPATCH = 15 LIBPATCH = 6
PYDEPS = ["pydantic<2.0"]
DEFAULT_RELATION_NAME = "ingress" DEFAULT_RELATION_NAME = "ingress"
RELATION_INTERFACE = "ingress" RELATION_INTERFACE = "ingress"
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"}
try:
import jsonschema
DO_VALIDATION = True class DatabagModel(BaseModel):
except ModuleNotFoundError: """Base databag model."""
log.warning(
"The `ingress` library needs the `jsonschema` package to be able " class Config:
"to do runtime data validation; without it, it will still work but validation " """Pydantic config."""
"will be disabled. \n"
"It is recommended to add `jsonschema` to the 'requirements.txt' of your charm, " allow_population_by_field_name = True
"which will enable this feature." """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() if k not in BUILTIN_JUJU_KEYS}
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.error(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()
dct = self.dict()
for key, field in self.__fields__.items(): # type: ignore
value = dct[key]
databag[field.alias or key] = json.dumps(value)
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(
description="Whether to strip the prefix from the ingress url.", alias="strip-prefix"
)
redirect_https: Optional[bool] = Field(
description="Whether to redirect http traffic to https.", alias="redirect-https"
) )
DO_VALIDATION = False
INGRESS_REQUIRES_APP_SCHEMA = { scheme: Optional[str] = Field(
"type": "object", default="http", description="What scheme to use in the generated ingress url"
"properties": { )
"model": {"type": "string"},
"name": {"type": "string"},
"host": {"type": "string"},
"port": {"type": "string"},
"strip-prefix": {"type": "string"},
"redirect-https": {"type": "string"},
},
"required": ["model", "name", "host", "port"],
}
INGRESS_PROVIDES_APP_SCHEMA = { @validator("scheme", pre=True)
"type": "object", def validate_scheme(cls, scheme): # noqa: N805 # pydantic wants 'cls' as first arg
"properties": { """Validate scheme arg."""
"ingress": {"type": "object", "properties": {"url": {"type": "string"}}}, if scheme not in {"http", "https", "h2c"}:
}, raise ValueError("invalid scheme: should be one of `http|https|h2c`")
"required": ["ingress"], return scheme
}
try: @validator("port", pre=True)
from typing import TypedDict def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg
except ImportError: """Validate port."""
from typing_extensions import TypedDict # py35 compatibility assert isinstance(port, int), type(port)
assert 0 < port < 65535, "port out of TCP range"
# Model of the data a unit implementing the requirer will need to provide. return port
RequirerData = TypedDict(
"RequirerData",
{
"model": str,
"name": str,
"host": str,
"port": int,
"strip-prefix": bool,
"redirect-https": bool,
},
total=False,
)
# Provider ingress data model.
ProviderIngressData = TypedDict("ProviderIngressData", {"url": str})
# Provider application databag model.
ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData}) # type: ignore
def _validate_data(data, schema): class IngressRequirerUnitData(DatabagModel):
"""Checks whether `data` matches `schema`. """Ingress requirer unit databag model."""
Will raise DataValidationError if the data is not valid, else return None. host: str = Field(description="Hostname the unit wishes to be exposed.")
"""
if not DO_VALIDATION: @validator("host", pre=True)
return def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg
try: """Validate host."""
jsonschema.validate(instance=data, schema=schema) assert isinstance(host, str), type(host)
except jsonschema.ValidationError as e: return host
raise DataValidationError(data, schema) from e
class DataValidationError(RuntimeError): 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.""" """Raised when data validation fails on IPU relation data."""
@ -156,7 +232,7 @@ class _IngressPerAppBase(Object):
"""Base class for IngressPerUnit interface classes.""" """Base class for IngressPerUnit interface classes."""
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME):
super().__init__(charm, relation_name + "_V1") super().__init__(charm, relation_name)
self.charm: CharmBase = charm self.charm: CharmBase = charm
self.relation_name = relation_name self.relation_name = relation_name
@ -234,13 +310,13 @@ class _IPAEvent(RelationEvent):
class IngressPerAppDataProvidedEvent(_IPAEvent): class IngressPerAppDataProvidedEvent(_IPAEvent):
"""Event representing that ingress data has been provided for an app.""" """Event representing that ingress data has been provided for an app."""
__args__ = ("name", "model", "port", "host", "strip_prefix", "redirect_https") __args__ = ("name", "model", "hosts", "strip_prefix", "redirect_https")
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
name: Optional[str] = None name: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
port: Optional[str] = None # sequence of hostname, port dicts
host: Optional[str] = None hosts: Sequence["IngressRequirerUnitData"] = ()
strip_prefix: bool = False strip_prefix: bool = False
redirect_https: bool = False redirect_https: bool = False
@ -256,12 +332,32 @@ class IngressPerAppProviderEvents(ObjectEvents):
data_removed = EventSource(IngressPerAppDataRemovedEvent) data_removed = EventSource(IngressPerAppDataRemovedEvent)
@dataclass
class IngressRequirerData:
"""Data exposed by the ingress requirer to the provider."""
app: "IngressRequirerAppData"
units: List["IngressRequirerUnitData"]
class TlsProviderType(typing.Protocol):
"""Placeholder."""
@property
def enabled(self) -> bool: # type: ignore
"""Placeholder."""
class IngressPerAppProvider(_IngressPerAppBase): class IngressPerAppProvider(_IngressPerAppBase):
"""Implementation of the provider of ingress.""" """Implementation of the provider of ingress."""
on = IngressPerAppProviderEvents() # type: ignore on = IngressPerAppProviderEvents() # type: ignore
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): def __init__(
self,
charm: CharmBase,
relation_name: str = DEFAULT_RELATION_NAME,
):
"""Constructor for IngressPerAppProvider. """Constructor for IngressPerAppProvider.
Args: Args:
@ -275,15 +371,14 @@ class IngressPerAppProvider(_IngressPerAppBase):
# created, joined or changed: if remote side has sent the required data: # created, joined or changed: if remote side has sent the required data:
# notify listeners. # notify listeners.
if self.is_ready(event.relation): if self.is_ready(event.relation):
data = self._get_requirer_data(event.relation) data = self.get_data(event.relation)
self.on.data_provided.emit( # type: ignore self.on.data_provided.emit( # type: ignore
event.relation, event.relation,
data["name"], data.app.name,
data["model"], data.app.model,
data["port"], [unit.dict() for unit in data.units],
data["host"], data.app.strip_prefix or False,
data.get("strip-prefix", False), data.app.redirect_https or False,
data.get("redirect-https", False),
) )
def _handle_relation_broken(self, event): def _handle_relation_broken(self, event):
@ -303,32 +398,39 @@ class IngressPerAppProvider(_IngressPerAppBase):
return return
del relation.data[self.app]["ingress"] del relation.data[self.app]["ingress"]
def _get_requirer_data(self, relation: Relation) -> RequirerData: # type: ignore def _get_requirer_units_data(self, relation: Relation) -> List["IngressRequirerUnitData"]:
"""Fetch and validate the requirer's app databag. """Fetch and validate the requirer's app databag."""
out: List["IngressRequirerUnitData"] = []
For convenience, we convert 'port' to integer. unit: Unit
""" for unit in relation.units:
if not relation.app or not relation.app.name: # type: ignore databag = relation.data[unit]
# Handle edge case where remote app name can be missing, e.g., try:
# relation_broken events. data = IngressRequirerUnitData.load(databag)
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34 out.append(data)
return {} except pydantic.ValidationError:
log.info(f"failed to validate remote unit data for {unit}")
raise
return out
databag = relation.data[relation.app] @staticmethod
remote_data: Dict[str, Union[int, str]] = {} def _get_requirer_app_data(relation: Relation) -> "IngressRequirerAppData":
for k in ("port", "host", "model", "name", "mode", "strip-prefix", "redirect-https"): """Fetch and validate the requirer's app databag."""
v = databag.get(k) app = relation.app
if v is not None: if app is None:
remote_data[k] = v raise NotReadyError(relation)
_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") == "true")
remote_data["redirect-https"] = bool(remote_data.get("redirect-https", "false") == "true")
return typing.cast(RequirerData, remote_data)
def get_data(self, relation: Relation) -> RequirerData: # type: ignore databag = relation.data[app]
"""Fetch the remote app's databag, i.e. the requirer data.""" return IngressRequirerAppData.load(databag)
return self._get_requirer_data(relation)
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): def is_ready(self, relation: Optional[Relation] = None):
"""The Provider is ready if the requirer has sent valid data.""" """The Provider is ready if the requirer has sent valid data."""
@ -336,38 +438,35 @@ class IngressPerAppProvider(_IngressPerAppBase):
return any(map(self.is_ready, self.relations)) return any(map(self.is_ready, self.relations))
try: try:
return bool(self._get_requirer_data(relation)) self.get_data(relation)
except DataValidationError as e: except (DataValidationError, NotReadyError) as e:
log.warning("Requirer not ready; validation error encountered: %s" % str(e)) log.debug("Provider not ready; validation error encountered: %s" % str(e))
return False return False
return True
def _provided_url(self, relation: Relation) -> ProviderIngressData: # type: ignore def _published_url(self, relation: Relation) -> Optional["IngressProviderAppData"]:
"""Fetch and validate this app databag; return the ingress url.""" """Fetch and validate this app databag; return the ingress url."""
if not relation.app or not relation.app.name or not self.unit.is_leader(): # type: ignore if not self.is_ready(relation) or not self.unit.is_leader():
# Handle edge case where remote app name can be missing, e.g., # Handle edge case where remote app name can be missing, e.g.,
# relation_broken events. # relation_broken events.
# Also, only leader units can read own app databags. # Also, only leader units can read own app databags.
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34 # FIXME https://github.com/canonical/traefik-k8s-operator/issues/34
return typing.cast(ProviderIngressData, {}) # noqa return None
# fetch the provider's app databag # fetch the provider's app databag
raw_data = relation.data[self.app].get("ingress") databag = relation.data[self.app]
if not raw_data: if not databag.get("ingress"):
raise RuntimeError("This application did not `publish_url` yet.") raise NotReadyError("This application did not `publish_url` yet.")
ingress: ProviderIngressData = yaml.safe_load(raw_data) return IngressProviderAppData.load(databag)
_validate_data({"ingress": ingress}, INGRESS_PROVIDES_APP_SCHEMA)
return ingress
def publish_url(self, relation: Relation, url: str): def publish_url(self, relation: Relation, url: str):
"""Publish to the app databag the ingress url.""" """Publish to the app databag the ingress url."""
ingress = {"url": url} ingress_url = {"url": url}
ingress_data = {"ingress": ingress} IngressProviderAppData.parse_obj({"ingress": ingress_url}).dump(relation.data[self.app])
_validate_data(ingress_data, INGRESS_PROVIDES_APP_SCHEMA)
relation.data[self.app]["ingress"] = yaml.safe_dump(ingress)
@property @property
def proxied_endpoints(self): def proxied_endpoints(self) -> Dict[str, str]:
"""Returns the ingress settings provided to applications by this IngressPerAppProvider. """Returns the ingress settings provided to applications by this IngressPerAppProvider.
For example, when this IngressPerAppProvider has provided the For example, when this IngressPerAppProvider has provided the
@ -385,11 +484,25 @@ class IngressPerAppProvider(_IngressPerAppBase):
results = {} results = {}
for ingress_relation in self.relations: for ingress_relation in self.relations:
assert ( if not ingress_relation.app:
ingress_relation.app log.warning(
), "no app in relation (shouldn't happen)" # for type checker f"no app in relation {ingress_relation} when fetching proxied endpoints: skipping"
results[ingress_relation.app.name] = self._provided_url(ingress_relation) )
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
results[ingress_relation.app.name] = ingress_data.ingress.dict()
return results return results
@ -430,6 +543,9 @@ class IngressPerAppRequirer(_IngressPerAppBase):
port: Optional[int] = None, port: Optional[int] = None,
strip_prefix: bool = False, strip_prefix: bool = False,
redirect_https: 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: typing.Callable[[], str] = lambda: "http",
): ):
"""Constructor for IngressRequirer. """Constructor for IngressRequirer.
@ -445,7 +561,8 @@ class IngressPerAppRequirer(_IngressPerAppBase):
host: Hostname to be used by the ingress provider to address the requiring host: Hostname to be used by the ingress provider to address the requiring
application; if unspecified, the default Kubernetes service name will be used. application; if unspecified, the default Kubernetes service name will be used.
strip_prefix: configure Traefik to strip the path prefix. strip_prefix: configure Traefik to strip the path prefix.
redirect_https: redirect incoming requests to the HTTPS. redirect_https: redirect incoming requests to HTTPS.
scheme: callable returning the scheme to use when constructing the ingress url.
Request Args: Request Args:
port: the port of the service port: the port of the service
@ -455,6 +572,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
self.relation_name = relation_name self.relation_name = relation_name
self._strip_prefix = strip_prefix self._strip_prefix = strip_prefix
self._redirect_https = redirect_https self._redirect_https = redirect_https
self._get_scheme = scheme
self._stored.set_default(current_url=None) # type: ignore self._stored.set_default(current_url=None) # type: ignore
@ -467,7 +585,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
def _handle_relation(self, event): def _handle_relation(self, event):
# created, joined or changed: if we have auto data: publish it # created, joined or changed: if we have auto data: publish it
self._publish_auto_data(event.relation) self._publish_auto_data()
if self.is_ready(): if self.is_ready():
# Avoid spurious events, emit only when there is a NEW URL available # Avoid spurious events, emit only when there is a NEW URL available
@ -486,56 +604,93 @@ class IngressPerAppRequirer(_IngressPerAppBase):
def _handle_upgrade_or_leader(self, event): def _handle_upgrade_or_leader(self, event):
"""On upgrade/leadership change: ensure we publish the data we have.""" """On upgrade/leadership change: ensure we publish the data we have."""
for relation in self.relations: self._publish_auto_data()
self._publish_auto_data(relation)
def is_ready(self): def is_ready(self):
"""The Requirer is ready if the Provider has sent valid data.""" """The Requirer is ready if the Provider has sent valid data."""
try: try:
return bool(self._get_url_from_relation_data()) return bool(self._get_url_from_relation_data())
except DataValidationError as e: except DataValidationError as e:
log.warning("Requirer not ready; validation error encountered: %s" % str(e)) log.debug("Requirer not ready; validation error encountered: %s" % str(e))
return False return False
def _publish_auto_data(self, relation: Relation): def _publish_auto_data(self):
if self._auto_data and self.unit.is_leader(): if self._auto_data:
host, port = self._auto_data host, port = self._auto_data
self.provide_ingress_requirements(host=host, port=port) self.provide_ingress_requirements(host=host, port=port)
def provide_ingress_requirements(self, *, host: Optional[str] = None, port: int): def provide_ingress_requirements(
self,
*,
scheme: Optional[str] = None,
host: Optional[str] = None,
port: int,
):
"""Publishes the data that Traefik needs to provide ingress. """Publishes the data that Traefik needs to provide ingress.
NB only the leader unit is supposed to do this.
Args: 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 host: Hostname to be used by the ingress provider to address the
requirer unit; if unspecified, FQDN will be used instead requirer unit; if unspecified, FQDN will be used instead
port: the port of the service (required) port: the port of the service (required)
""" """
# get only the leader to publish the data since we only for relation in self.relations:
# require one unit to publish it -- it will not differ between units, self._provide_ingress_requirements(scheme, host, port, relation)
# unlike in ingress-per-unit.
assert self.unit.is_leader(), "only leaders should do this."
assert self.relation, "no relation"
def _provide_ingress_requirements(
self,
scheme: Optional[str],
host: Optional[str],
port: int,
relation: Relation,
):
if self.unit.is_leader():
self._publish_app_data(scheme, port, relation)
self._publish_unit_data(host, relation)
def _publish_unit_data(
self,
host: Optional[str],
relation: Relation,
):
if not host: if not host:
host = socket.getfqdn() host = socket.getfqdn()
data = { unit_databag = relation.data[self.unit]
"model": self.model.name, try:
"name": self.app.name, IngressRequirerUnitData(host=host).dump(unit_databag)
"host": host, except pydantic.ValidationError as e:
"port": str(port), 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
if self._strip_prefix: def _publish_app_data(
data["strip-prefix"] = "true" self,
scheme: Optional[str],
port: int,
relation: Relation,
):
# assumes leadership!
app_databag = relation.data[self.app]
if self._redirect_https: if not scheme:
data["redirect-https"] = "true" # If scheme was not provided, use the one given to the constructor.
scheme = self._get_scheme()
_validate_data(data, INGRESS_REQUIRES_APP_SCHEMA) try:
self.relation.data[self.app].update(data) 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 @property
def relation(self): def relation(self):
@ -553,7 +708,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
# fetch the provider's app databag # fetch the provider's app databag
try: try:
raw = relation.data.get(relation.app, {}).get("ingress") databag = relation.data[relation.app]
except ModelError as e: except ModelError as e:
log.debug( log.debug(
f"Error {e} attempting to read remote app data; " f"Error {e} attempting to read remote app data; "
@ -561,12 +716,10 @@ class IngressPerAppRequirer(_IngressPerAppBase):
) )
return None return None
if not raw: if not databag: # not ready yet
return None return None
ingress: ProviderIngressData = yaml.safe_load(raw) return str(IngressProviderAppData.load(databag).ingress.url)
_validate_data({"ingress": ingress}, INGRESS_PROVIDES_APP_SCHEMA)
return ingress["url"]
@property @property
def url(self) -> Optional[str]: def url(self) -> Optional[str]:
@ -574,6 +727,8 @@ class IngressPerAppRequirer(_IngressPerAppBase):
Returns None if the URL isn't available yet. Returns None if the URL isn't available yet.
""" """
data = self._stored.current_url or self._get_url_from_relation_data() # type: ignore data = (
assert isinstance(data, (str, type(None))) # for static checker typing.cast(Optional[str], self._stored.current_url) # type: ignore
or self._get_url_from_relation_data()
)
return data return data

View File

@ -1,5 +1,6 @@
ops ops
jsonschema jsonschema
pydantic<2.0
jinja2 jinja2
git+https://github.com/openstack/charm-ops-sunbeam#egg=ops_sunbeam git+https://github.com/openstack/charm-ops-sunbeam#egg=ops_sunbeam
lightkube lightkube

View File

@ -23,13 +23,15 @@ This charm provide Designate services as part of an OpenStack deployment
""" """
import logging import logging
import secrets
from typing import ( from typing import (
Callable, Callable,
List, List,
Mapping, Mapping,
Optional,
) )
import charms.bind9_k8s.v0.bind_rndc as bind_rndc import charms.designate_bind_k8s.v0.bind_rndc as bind_rndc
import ops import ops
import ops.charm import ops.charm
import ops_sunbeam.charm as sunbeam_charm import ops_sunbeam.charm as sunbeam_charm
@ -47,6 +49,13 @@ logger = logging.getLogger(__name__)
DESIGNATE_CONTAINER = "designate" DESIGNATE_CONTAINER = "designate"
BIND_RNDC_RELATION = "dns-backend" BIND_RNDC_RELATION = "dns-backend"
RNDC_SECRET_PREFIX = "rndc_" RNDC_SECRET_PREFIX = "rndc_"
NONCE_SECRET_LABEL = "nonce-rndc"
class NoRelationError(Exception):
"""No relation found."""
pass
class DesignatePebbleHandler(sunbeam_chandlers.WSGIPebbleHandler): class DesignatePebbleHandler(sunbeam_chandlers.WSGIPebbleHandler):
@ -175,15 +184,33 @@ class BindRndcRequiresRelationHandler(sunbeam_rhandlers.RelationHandler):
"""Setup event handler for the relation.""" """Setup event handler for the relation."""
interface = bind_rndc.BindRndcRequires(self.charm, BIND_RNDC_RELATION) interface = bind_rndc.BindRndcRequires(self.charm, BIND_RNDC_RELATION)
self.framework.observe( self.framework.observe(
interface.on.bind_rndc_ready, interface.on.connected,
self._on_bind_rndc_connected,
)
self.framework.observe(
interface.on.ready,
self._on_bind_rndc_ready, self._on_bind_rndc_ready,
) )
self.framework.observe( self.framework.observe(
interface.on.goneaway, interface.on.goneaway,
self._on_bind_rndc_goneaway, self._on_bind_rndc_goneaway,
) )
try:
self.request_rndc_key(interface, self._relation)
except NoRelationError:
pass
return interface return interface
def _on_bind_rndc_connected(self, event: bind_rndc.BindRndcConnectedEvent):
"""Handle bind rndc connected event."""
relation = self.model.get_relation(
event.relation_name, event.relation_id
)
if relation is not None:
self.request_rndc_key(self.interface, relation)
def _on_bind_rndc_ready(self, event: bind_rndc.BindRndcReadyEvent): def _on_bind_rndc_ready(self, event: bind_rndc.BindRndcReadyEvent):
"""Handle bind rndc ready event.""" """Handle bind rndc ready event."""
self.callback_f(event) self.callback_f(event)
@ -192,12 +219,21 @@ class BindRndcRequiresRelationHandler(sunbeam_rhandlers.RelationHandler):
"""Handle bind rndc goneaway event.""" """Handle bind rndc goneaway event."""
self.callback_f(event) self.callback_f(event)
def request_rndc_key(
self, interface: bind_rndc.BindRndcRequires, relation: ops.Relation
):
"""Request credentials from vault-kv relation."""
nonce = self.charm.get_nonce()
if nonce is None:
return
interface.request_rndc_key(relation, nonce)
@property @property
def _relation(self) -> ops.Relation: def _relation(self) -> ops.Relation:
"""Get relation.""" """Get relation."""
relation = self.framework.model.get_relation(self.relation_name) relation = self.framework.model.get_relation(self.relation_name)
if relation is None: if relation is None:
raise Exception("Relation not found") raise NoRelationError("Relation not found")
return relation return relation
@property @property
@ -205,7 +241,6 @@ class BindRndcRequiresRelationHandler(sunbeam_rhandlers.RelationHandler):
"""Ready when a key is available for current unit.""" """Ready when a key is available for current unit."""
try: try:
relation = self._relation relation = self._relation
self.interface.reconcile_rndc_key(relation)
return self.interface.get_rndc_key(relation) is not None return self.interface.get_rndc_key(relation) is not None
except Exception: except Exception:
return False return False
@ -224,7 +259,7 @@ class BindRndcRequiresRelationHandler(sunbeam_rhandlers.RelationHandler):
) )
secret_value = secret.get_content()["secret"] secret_value = secret.get_content()["secret"]
rndc_key["secret"] = secret_value rndc_key["secret"] = secret_value
rndc_key["name"] = self.interface.nonce() rndc_key["name"] = self.interface.nonce(self._relation)
return rndc_key return rndc_key
@ -284,7 +319,19 @@ class DesignateOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
BIND_RNDC_RELATION, BIND_RNDC_RELATION,
} }
def configure_unit(self, event: ops.framework.EventBase) -> None: def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.install, self._on_install)
def _on_install(self, event: ops.EventBase) -> None:
"""Handle install event."""
self.unit.add_secret(
{"nonce": secrets.token_hex(16)},
label=NONCE_SECRET_LABEL,
description="nonce for bind-rndc relation",
)
def configure_unit(self, event: ops.EventBase) -> None:
"""Run configuration on this unit.""" """Run configuration on this unit."""
self.check_leader_ready() self.check_leader_ready()
self.check_relation_handlers_ready() self.check_relation_handlers_ready()
@ -420,6 +467,14 @@ class DesignateOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"Updating pools failed" "Updating pools failed"
) )
def get_nonce(self) -> Optional[str]:
"""Return nonce stored in secret."""
try:
secret = self.model.get_secret(label=NONCE_SECRET_LABEL)
return secret.get_content()["nonce"]
except ops.SecretNotFoundError:
return None
if __name__ == "__main__": if __name__ == "__main__":
main(DesignateOperatorCharm) main(DesignateOperatorCharm)

View File

@ -10,7 +10,7 @@ applications:
# If this isn't present, the units will hang at "installing agent". # If this isn't present, the units will hang at "installing agent".
traefik: traefik:
charm: ch:traefik-k8s charm: ch:traefik-k8s
channel: 1.0/stable channel: 1.0/candidate
scale: 1 scale: 1
trust: true trust: true
options: options:
@ -35,9 +35,9 @@ applications:
fernet-keys: 5M fernet-keys: 5M
credential-keys: 5M credential-keys: 5M
bind9: designate-bind:
charm: ch:bind9-k8s charm: ch:designate-bind-k8s
channel: latest/edge channel: 9/edge
scale: 1 scale: 1
trust: false trust: false
@ -67,5 +67,5 @@ relations:
- designate:ingress-internal - designate:ingress-internal
- - traefik:ingress - - traefik:ingress
- designate:ingress-public - designate:ingress-public
- - bind9:dns-backend - - designate-bind:dns-backend
- designate:dns-backend - designate:dns-backend

View File

@ -30,7 +30,7 @@ target_deploy_status:
mysql: mysql:
workload-status: active workload-status: active
workload-status-message-regex: '^.*$' workload-status-message-regex: '^.*$'
bind9: designate-bind:
workload-status: active workload-status: active
workload-status-message-regex: '^.*$' workload-status-message-regex: '^.*$'
designate: designate:

View File

@ -17,9 +17,6 @@
"""Unit tests for Designate operator.""" """Unit tests for Designate operator."""
import json import json
from unittest.mock import (
patch,
)
import ops_sunbeam.test_utils as test_utils import ops_sunbeam.test_utils as test_utils
from ops.testing import ( from ops.testing import (
@ -86,12 +83,9 @@ class TestDesignateOperatorCharm(test_utils.CharmTestCase):
test_utils.set_all_pebbles_ready(self.harness) test_utils.set_all_pebbles_ready(self.harness)
self.assertEqual(len(self.harness.charm.seen_events), 1) self.assertEqual(len(self.harness.charm.seen_events), 1)
@patch( def test_all_relations(self):
"secrets.token_hex",
)
def test_all_relations(self, token_hex):
"""Test all integrations for operator.""" """Test all integrations for operator."""
token_hex.return_value = "abfcdfea12" self.harness.charm.on.install.emit()
self.harness.set_leader() self.harness.set_leader()
test_utils.set_all_pebbles_ready(self.harness) test_utils.set_all_pebbles_ready(self.harness)
# this adds all the default/common relations # this adds all the default/common relations