Migrate traefik ingress to v2

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

Change-Id: Ib7b92b482c080b1a96e5de16ff551db7f4e06b2f
This commit is contained in:
Guillaume Boutry 2023-09-26 17:44:38 +02:00
parent 95487b9f0a
commit c11f1d4079
5 changed files with 329 additions and 155 deletions

View File

@ -18,5 +18,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,4 +4,4 @@ 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

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,97 +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 = 12 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"},
},
"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 compat 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},
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."""
@ -183,8 +267,8 @@ class _IngressPerAppBase(Object):
class _IPAEvent(RelationEvent): class _IPAEvent(RelationEvent):
__args__ = () # type: Tuple[str, ...] __args__: Tuple[str, ...] = ()
__optional_kwargs__ = {} # type: Dict[str, Any] __optional_kwargs__: Dict[str, Any] = {}
@classmethod @classmethod
def __attrs__(cls): def __attrs__(cls):
@ -226,14 +310,15 @@ 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") __args__ = ("name", "model", "hosts", "strip_prefix", "redirect_https")
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
name = None # type: Optional[str] name: Optional[str] = None
model = None # type: Optional[str] model: Optional[str] = None
port = None # type: Optional[str] # sequence of hostname, port dicts
host = None # type: Optional[str] hosts: Sequence["IngressRequirerUnitData"] = ()
strip_prefix = False # type: bool strip_prefix: bool = False
redirect_https: bool = False
class IngressPerAppDataRemovedEvent(RelationEvent): class IngressPerAppDataRemovedEvent(RelationEvent):
@ -247,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:
@ -266,14 +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,
) )
def _handle_relation_broken(self, event): def _handle_relation_broken(self, event):
@ -293,31 +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 = {} # type: Dict[str, Union[int, str]] def _get_requirer_app_data(relation: Relation) -> "IngressRequirerAppData":
for k in ("port", "host", "model", "name", "mode", "strip-prefix"): """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))
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."""
@ -325,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
@ -374,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
@ -387,7 +511,7 @@ class IngressPerAppReadyEvent(_IPAEvent):
__args__ = ("url",) __args__ = ("url",)
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
url = None # type: Optional[str] url: Optional[str] = None
class IngressPerAppRevokedEvent(RelationEvent): class IngressPerAppRevokedEvent(RelationEvent):
@ -418,6 +542,10 @@ class IngressPerAppRequirer(_IngressPerAppBase):
host: Optional[str] = None, host: Optional[str] = None,
port: Optional[int] = None, port: Optional[int] = None,
strip_prefix: bool = False, 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: typing.Callable[[], str] = lambda: "http",
): ):
"""Constructor for IngressRequirer. """Constructor for IngressRequirer.
@ -433,6 +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 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
@ -441,6 +571,8 @@ class IngressPerAppRequirer(_IngressPerAppBase):
self.charm: CharmBase = charm self.charm: CharmBase = charm
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._get_scheme = scheme
self._stored.set_default(current_url=None) # type: ignore self._stored.set_default(current_url=None) # type: ignore
@ -453,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
@ -472,53 +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]
_validate_data(data, INGRESS_REQUIRES_APP_SCHEMA) if not scheme:
self.relation.data[self.app].update(data) # 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 @property
def relation(self): def relation(self):
@ -536,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; "
@ -544,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]:
@ -557,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

@ -7,6 +7,7 @@
cryptography cryptography
jinja2 jinja2
jsonschema jsonschema
pydantic<2.0
lightkube lightkube
lightkube-models lightkube-models
ops ops

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: