Add ingress healthcheck params to charms

This change adds a healthcheck params dict to k8s charms for
use by traefik via the ingress relation. It contains a path,
interval, and timeout value. This allows traefik to detect
down nodes and remove them from the loadbalancer rotation.
Unless overridden in the charm, a default path of "/" is
passed in the ingress relation. Interval and timeout
are optional and will use default values of 30s and 5s,
respectively, unless overridden in the charm. Some charms
define a "/healthcheck" path in api-paste.ini which has been
used in place of the default "/" path.

Closes-Bug: #2077269
Change-Id: I355728f338e9a29fcf202cc629a977b49b2d8990
This commit is contained in:
Myles Penner 2025-02-27 09:05:05 -08:00
parent 97e82b6cec
commit 1f30344dbf
No known key found for this signature in database
GPG Key ID: 008B063DE04D5F62
12 changed files with 189 additions and 25 deletions

View File

@ -283,6 +283,11 @@ class AodhOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Ingress Port for API service.""" """Ingress Port for API service."""
return 8042 return 8042
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
def get_pebble_handlers( def get_pebble_handlers(
self, self,
) -> List[sunbeam_chandlers.ServicePebbleHandler]: ) -> List[sunbeam_chandlers.ServicePebbleHandler]:

View File

@ -418,6 +418,11 @@ class BarbicanOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Default port.""" """Default port."""
return 9311 return 9311
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
@property @property
def healthcheck_http_url(self) -> str: def healthcheck_http_url(self) -> str:
"""Healthcheck HTTP URL for the service.""" """Healthcheck HTTP URL for the service."""

View File

@ -298,6 +298,11 @@ class CinderOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Public ingress port for service.""" """Public ingress port for service."""
return 8776 return 8776
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
@property @property
def service_conf(self) -> str: def service_conf(self) -> str:
"""Service default configuration file.""" """Service default configuration file."""

View File

@ -455,6 +455,11 @@ class DesignateOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Ingress Port for API service.""" """Ingress Port for API service."""
return 9001 return 9001
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
@property @property
def ns_records(self) -> List[str]: def ns_records(self) -> List[str]:
"""Get nameserver records.""" """Get nameserver records."""

View File

@ -422,6 +422,11 @@ class GlanceOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Default ingress port.""" """Default ingress port."""
return 9292 return 9292
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
@property @property
def healthcheck_http_url(self) -> str: def healthcheck_http_url(self) -> str:
"""Healthcheck HTTP URL for the service.""" """Healthcheck HTTP URL for the service."""

View File

@ -196,6 +196,11 @@ class GnocchiOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Ingress Port for API service.""" """Ingress Port for API service."""
return 8041 return 8041
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
@property @property
def healthcheck_http_url(self) -> str: def healthcheck_http_url(self) -> str:
"""Healthcheck HTTP URL for the service.""" """Healthcheck HTTP URL for the service."""

View File

@ -1440,7 +1440,12 @@ export OS_AUTH_VERSION=3
@property @property
def healthcheck_http_url(self) -> str: def healthcheck_http_url(self) -> str:
"""Healthcheck HTTP URL for the service.""" """Healthcheck HTTP URL for the service."""
return f"http://localhost:{self.default_public_ingress_port}/v3" return f"http://localhost:{self.default_public_ingress_port}/{self.ingress_healthcheck_path}"
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
def _create_fernet_secret(self) -> None: def _create_fernet_secret(self) -> None:
"""Create fernet juju secret. """Create fernet juju secret.

View File

@ -184,6 +184,11 @@ class MagnumOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Default public ingress port.""" """Default public ingress port."""
return 9511 return 9511
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
@property @property
def config_contexts(self) -> List[sunbeam_config_contexts.ConfigContext]: def config_contexts(self) -> List[sunbeam_config_contexts.ConfigContext]:
"""Generate list of configuration adapters for the charm.""" """Generate list of configuration adapters for the charm."""

View File

@ -302,6 +302,11 @@ class NeutronOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Public ingress port.""" """Public ingress port."""
return 9696 return 9696
@property
def ingress_healthcheck_path(self):
"""Healthcheck path for ingress relation."""
return "/healthcheck"
@property @property
def service_user(self) -> str: def service_user(self) -> str:
"""Service user file and directory ownership.""" """Service user file and directory ownership."""

View File

@ -56,13 +56,25 @@ import logging
import socket import socket
import typing import typing
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, List, MutableMapping, Optional, Sequence, Tuple, Union from functools import partial
from typing import (
Any,
Callable,
Dict,
List,
MutableMapping,
Optional,
Sequence,
Tuple,
Union,
cast,
)
import pydantic 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, Unit from ops.model import ModelError, Relation, Unit
from pydantic import AnyHttpUrl, BaseModel, Field, validator from pydantic import AnyHttpUrl, BaseModel, Field
# The unique Charmhub library identifier, never change it # The unique Charmhub library identifier, never change it
LIBID = "e6de2a5cd5b34422a204668f3b8f90d2" LIBID = "e6de2a5cd5b34422a204668f3b8f90d2"
@ -72,7 +84,7 @@ 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 = 15
PYDEPS = ["pydantic"] PYDEPS = ["pydantic"]
@ -84,6 +96,9 @@ BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"}
PYDANTIC_IS_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2 PYDANTIC_IS_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2
if PYDANTIC_IS_V1: if PYDANTIC_IS_V1:
from pydantic import validator
input_validator = partial(validator, pre=True)
class DatabagModel(BaseModel): # type: ignore class DatabagModel(BaseModel): # type: ignore
"""Base databag model.""" """Base databag model."""
@ -143,7 +158,9 @@ if PYDANTIC_IS_V1:
return databag return databag
else: else:
from pydantic import ConfigDict from pydantic import ConfigDict, field_validator
input_validator = partial(field_validator, mode="before")
class DatabagModel(BaseModel): class DatabagModel(BaseModel):
"""Base databag model.""" """Base databag model."""
@ -171,7 +188,7 @@ else:
k: json.loads(v) k: json.loads(v)
for k, v in databag.items() for k, v in databag.items()
# Don't attempt to parse model-external values # Don't attempt to parse model-external values
if k in {(f.alias or n) for n, f in cls.__fields__.items()} # type: ignore if k in {(f.alias or n) for n, f in cls.model_fields.items()} # type: ignore
} }
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
msg = f"invalid databag contents: expecting json. {databag}" msg = f"invalid databag contents: expecting json. {databag}"
@ -220,7 +237,7 @@ class IngressUrl(BaseModel):
class IngressProviderAppData(DatabagModel): class IngressProviderAppData(DatabagModel):
"""Ingress application databag schema.""" """Ingress application databag schema."""
ingress: IngressUrl ingress: Optional[IngressUrl] = None
class ProviderSchema(BaseModel): class ProviderSchema(BaseModel):
@ -229,12 +246,32 @@ class ProviderSchema(BaseModel):
app: IngressProviderAppData app: IngressProviderAppData
class IngressHealthCheck(BaseModel):
"""HealthCheck schema for Ingress."""
path: str = Field(description="The health check endpoint path (required).")
scheme: Optional[str] = Field(
default=None, description="Replaces the server URL scheme for the health check endpoint."
)
hostname: Optional[str] = Field(
default=None, description="Hostname to be set in the health check request."
)
port: Optional[int] = Field(
default=None, description="Replaces the server URL port for the health check endpoint."
)
interval: str = Field(default="30s", description="Frequency of the health check calls.")
timeout: str = Field(default="5s", description="Maximum duration for a health check request.")
class IngressRequirerAppData(DatabagModel): class IngressRequirerAppData(DatabagModel):
"""Ingress requirer application databag model.""" """Ingress requirer application databag model."""
model: str = Field(description="The model the application is in.") model: str = Field(description="The model the application is in.")
name: str = Field(description="the name of the app requesting ingress.") name: str = Field(description="the name of the app requesting ingress.")
port: int = Field(description="The port the app wishes to be exposed.") port: int = Field(description="The port the app wishes to be exposed.")
healthcheck_params: Optional[IngressHealthCheck] = Field(
default=None, description="Optional health check configuration for ingress."
)
# fields on top of vanilla 'ingress' interface: # fields on top of vanilla 'ingress' interface:
strip_prefix: Optional[bool] = Field( strip_prefix: Optional[bool] = Field(
@ -252,14 +289,14 @@ class IngressRequirerAppData(DatabagModel):
default="http", description="What scheme to use in the generated ingress url" default="http", description="What scheme to use in the generated ingress url"
) )
@validator("scheme", pre=True) @input_validator("scheme")
def validate_scheme(cls, scheme): # noqa: N805 # pydantic wants 'cls' as first arg def validate_scheme(cls, scheme): # noqa: N805 # pydantic wants 'cls' as first arg
"""Validate scheme arg.""" """Validate scheme arg."""
if scheme not in {"http", "https", "h2c"}: if scheme not in {"http", "https", "h2c"}:
raise ValueError("invalid scheme: should be one of `http|https|h2c`") raise ValueError("invalid scheme: should be one of `http|https|h2c`")
return scheme return scheme
@validator("port", pre=True) @input_validator("port")
def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg
"""Validate port.""" """Validate port."""
assert isinstance(port, int), type(port) assert isinstance(port, int), type(port)
@ -277,13 +314,13 @@ class IngressRequirerUnitData(DatabagModel):
"IP can only be None if the IP information can't be retrieved from juju.", "IP can only be None if the IP information can't be retrieved from juju.",
) )
@validator("host", pre=True) @input_validator("host")
def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg
"""Validate host.""" """Validate host."""
assert isinstance(host, str), type(host) assert isinstance(host, str), type(host)
return host return host
@validator("ip", pre=True) @input_validator("ip")
def validate_ip(cls, ip): # noqa: N805 # pydantic wants 'cls' as first arg def validate_ip(cls, ip): # noqa: N805 # pydantic wants 'cls' as first arg
"""Validate ip.""" """Validate ip."""
if ip is None: if ip is None:
@ -462,7 +499,10 @@ class IngressPerAppProvider(_IngressPerAppBase):
event.relation, event.relation,
data.app.name, data.app.name,
data.app.model, data.app.model,
[unit.dict() for unit in data.units], [
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.strip_prefix or False,
data.app.redirect_https or False, data.app.redirect_https or False,
) )
@ -549,7 +589,16 @@ class IngressPerAppProvider(_IngressPerAppBase):
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": url} ingress_url = {"url": url}
IngressProviderAppData(ingress=ingress_url).dump(relation.data[self.app]) # type: ignore try:
IngressProviderAppData(ingress=ingress_url).dump(relation.data[self.app]) # type: ignore
except pydantic.ValidationError as e:
# If we cannot validate the url as valid, publish an empty databag and log the error.
log.error(f"Failed to validate ingress url '{url}' - got ValidationError {e}")
log.error(
"url was not published to ingress relation for {relation.app}. This error is likely due to an"
" error or misconfiguration of the charm calling this library."
)
IngressProviderAppData(ingress=None).dump(relation.data[self.app]) # type: ignore
@property @property
def proxied_endpoints(self) -> Dict[str, Dict[str, str]]: def proxied_endpoints(self) -> Dict[str, Dict[str, str]]:
@ -587,10 +636,14 @@ class IngressPerAppProvider(_IngressPerAppBase):
if not ingress_data: if not ingress_data:
log.warning(f"relation {ingress_relation} not ready yet: try again in some time.") log.warning(f"relation {ingress_relation} not ready yet: try again in some time.")
continue continue
# Validation above means ingress cannot be None, but type checker doesn't know that.
ingress = ingress_data.ingress
ingress = cast(IngressProviderAppData, ingress)
if PYDANTIC_IS_V1: if PYDANTIC_IS_V1:
results[ingress_relation.app.name] = ingress_data.ingress.dict() results[ingress_relation.app.name] = ingress.dict()
else: else:
results[ingress_relation.app.name] = ingress_data.ingress.model_dump(mode=json) # type: ignore results[ingress_relation.app.name] = ingress.model_dump(mode="json")
return results return results
@ -635,6 +688,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
# fixme: this is horrible UX. # fixme: this is horrible UX.
# shall we switch to manually calling provide_ingress_requirements with all args when ready? # shall we switch to manually calling provide_ingress_requirements with all args when ready?
scheme: Union[Callable[[], str], str] = lambda: "http", scheme: Union[Callable[[], str], str] = lambda: "http",
healthcheck_params: Optional[Dict[str, Any]] = None,
): ):
"""Constructor for IngressRequirer. """Constructor for IngressRequirer.
@ -644,23 +698,34 @@ class IngressPerAppRequirer(_IngressPerAppBase):
All request args must be given as keyword args. All request args must be given as keyword args.
Args: Args:
charm: the charm that is instantiating the library. charm: The charm that is instantiating the library.
relation_name: the name of the relation endpoint to bind to (defaults to `ingress`); 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") the relation must be of interface type "ingress" and have a limit of 1.
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.
ip: Alternative addressing method other than host to be used by the ingress provider; 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. if unspecified, the binding address from the Juju network API will be used.
strip_prefix: configure Traefik to strip the path prefix. healthcheck_params: Optional dictionary containing health check
redirect_https: redirect incoming requests to HTTPS. configuration parameters conforming to the IngressHealthCheck schema. The dictionary must include:
scheme: callable returning the scheme to use when constructing the ingress url. - "path" (str): The health check endpoint path (required).
Or a string, if the scheme is known and stable at charm-init-time. It may also include:
- "scheme" (Optional[str]): Replaces the server URL scheme for the health check endpoint.
- "hostname" (Optional[str]): Hostname to be set in the health check request.
- "port" (Optional[int]): Replaces the server URL port for the health check endpoint.
- "interval" (str): Frequency of the health check calls (defaults to "30s" if omitted).
- "timeout" (str): Maximum duration for a health check request (defaults to "5s" if omitted).
If provided, "path" is required while "interval" and "timeout" will use Traefik's defaults when not specified.
strip_prefix: Configure Traefik to strip the path prefix.
redirect_https: Redirect incoming requests to HTTPS.
scheme: Either a callable that returns the scheme to use when constructing the ingress URL,
or a string if the scheme is known and stable at charm initialization.
Request Args: Request Args:
port: the port of the service port: the port of the service
""" """
super().__init__(charm, relation_name) super().__init__(charm, relation_name)
self.charm: CharmBase = charm self.charm: CharmBase = charm
self.healthcheck_params = healthcheck_params
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
@ -792,6 +857,11 @@ class IngressPerAppRequirer(_IngressPerAppBase):
port=port, port=port,
strip_prefix=self._strip_prefix, # type: ignore # pyright does not like aliases strip_prefix=self._strip_prefix, # type: ignore # pyright does not like aliases
redirect_https=self._redirect_https, # type: ignore # pyright does not like aliases redirect_https=self._redirect_https, # type: ignore # pyright does not like aliases
healthcheck_params=(
IngressHealthCheck(**self.healthcheck_params)
if self.healthcheck_params
else None
),
).dump(app_databag) ).dump(app_databag)
except pydantic.ValidationError as e: except pydantic.ValidationError as e:
msg = "failed to validate app data" msg = "failed to validate app data"
@ -825,7 +895,11 @@ class IngressPerAppRequirer(_IngressPerAppBase):
if not databag: # not ready yet if not databag: # not ready yet
return None return None
return str(IngressProviderAppData.load(databag).ingress.url) ingress = IngressProviderAppData.load(databag).ingress
if ingress is None:
return None
return str(ingress.url)
@property @property
def url(self) -> Optional[str]: def url(self) -> Optional[str]:
@ -837,4 +911,4 @@ class IngressPerAppRequirer(_IngressPerAppBase):
typing.cast(Optional[str], self._stored.current_url) # type: ignore typing.cast(Optional[str], self._stored.current_url) # type: ignore
or self._get_url_from_relation_data() or self._get_url_from_relation_data()
) )
return data return data

View File

@ -865,6 +865,44 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharmK8S):
"""List of endpoints for this service.""" """List of endpoints for this service."""
return [] return []
@property
def ingress_healthcheck_path(self):
"""Default ingress healthcheck path.
This value can be overridden at the charm level as shown in
keystone-k8s/src/charm.py.
"""
return "/"
@property
def ingress_healthcheck_interval(self):
"""Default ingress healthcheck interval.
This value can be overridden at the charm level. Time values
following Golang time.ParseDuration() format are valid.
"""
return "30s"
@property
def ingress_healthcheck_timeout(self):
"""Default ingress healthcheck timeout.
This value can be overridden at the charm level. Time values
following Golang time.ParseDuration() format are valid.
"""
return "5s"
@property
def ingress_healthcheck_params(self):
"""Dictionary of ingress healthcheck values."""
params = {
"path": self.ingress_healthcheck_path,
"interval": self.ingress_healthcheck_interval,
"timeout": self.ingress_healthcheck_timeout,
}
return params
def get_relation_handlers( def get_relation_handlers(
self, handlers: list[sunbeam_rhandlers.RelationHandler] | None = None self, handlers: list[sunbeam_rhandlers.RelationHandler] | None = None
) -> list[sunbeam_rhandlers.RelationHandler]: ) -> list[sunbeam_rhandlers.RelationHandler]:
@ -878,6 +916,7 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharmK8S):
"ingress-internal", "ingress-internal",
self.service_name, self.service_name,
self.default_public_ingress_port, self.default_public_ingress_port,
self.ingress_healthcheck_params,
self._ingress_changed, self._ingress_changed,
"ingress-internal" in self.mandatory_relations, "ingress-internal" in self.mandatory_relations,
) )
@ -888,6 +927,7 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharmK8S):
"ingress-public", "ingress-public",
self.service_name, self.service_name,
self.default_public_ingress_port, self.default_public_ingress_port,
self.ingress_healthcheck_params,
self._ingress_changed, self._ingress_changed,
"ingress-public" in self.mandatory_relations, "ingress-public" in self.mandatory_relations,
) )

View File

@ -22,7 +22,9 @@ import secrets
import string import string
import typing import typing
from typing import ( from typing import (
Any,
Callable, Callable,
Dict,
) )
from urllib.parse import ( from urllib.parse import (
urlparse, urlparse,
@ -193,6 +195,7 @@ class IngressHandler(RelationHandler):
relation_name: str, relation_name: str,
service_name: str, service_name: str,
default_ingress_port: int, default_ingress_port: int,
ingress_healthcheck_params: Dict[str, Any],
callback_f: Callable, callback_f: Callable,
mandatory: bool = False, mandatory: bool = False,
) -> None: ) -> None:
@ -200,6 +203,7 @@ class IngressHandler(RelationHandler):
super().__init__(charm, relation_name, callback_f, mandatory) super().__init__(charm, relation_name, callback_f, mandatory)
self.default_ingress_port = default_ingress_port self.default_ingress_port = default_ingress_port
self.service_name = service_name self.service_name = service_name
self.ingress_healthcheck_params = ingress_healthcheck_params
def setup_event_handler(self) -> ops.framework.Object: def setup_event_handler(self) -> ops.framework.Object:
"""Configure event handlers for an Ingress relation.""" """Configure event handlers for an Ingress relation."""
@ -212,6 +216,7 @@ class IngressHandler(RelationHandler):
self.charm, self.charm,
self.relation_name, self.relation_name,
port=self.default_ingress_port, port=self.default_ingress_port,
healthcheck_params=self.ingress_healthcheck_params,
) )
self.framework.observe(interface.on.ready, self._on_ingress_ready) self.framework.observe(interface.on.ready, self._on_ingress_ready)
self.framework.observe(interface.on.revoked, self._on_ingress_revoked) self.framework.observe(interface.on.revoked, self._on_ingress_revoked)