Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(charm): Update Redis relation to be able to use HA #8

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def __init__(self, *args):
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version

LIBPATCH = 35
LIBPATCH = 36

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1050,6 +1050,7 @@ def __init__(

self.framework.observe(self._charm.on.leader_elected, self._update_all_dashboards_from_dir)
self.framework.observe(self._charm.on.upgrade_charm, self._update_all_dashboards_from_dir)
self.framework.observe(self._charm.on.config_changed, self._update_all_dashboards_from_dir)

self.framework.observe(
self._charm.on[self._relation_name].relation_created,
Expand Down
81 changes: 76 additions & 5 deletions examples/django/charm/lib/charms/loki_k8s/v1/loki_push_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,25 @@ def _alert_rules_error(self, event):

Units of consumer charm send their alert rules over app relation data using the `alert_rules`
key.

## Charm logging
The `charms.loki_k8s.v0.charm_logging` library can be used in conjunction with this one to configure python's
logging module to forward all logs to Loki via the loki-push-api interface.

```python
from lib.charms.loki_k8s.v0.charm_logging import log_charm
from lib.charms.loki_k8s.v1.loki_push_api import charm_logging_config, LokiPushApiConsumer

@log_charm(logging_endpoint="my_endpoints", server_cert="cert_path")
class MyCharm(...):
_cert_path = "/path/to/cert/on/charm/container.crt"
def __init__(self, ...):
self.logging = LokiPushApiConsumer(...)
self.my_endpoints, self.cert_path = charm_logging_config(
self.logging, self._cert_path)
```

Do this, and all charm logs will be forwarded to Loki as soon as a relation is formed.
"""

import json
Expand Down Expand Up @@ -527,7 +546,7 @@ def _alert_rules_error(self, event):

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

PYDEPS = ["cosl"]

Expand Down Expand Up @@ -577,7 +596,11 @@ def _alert_rules_error(self, event):
GRPC_LISTEN_PORT_START = 9095 # odd start port


class RelationNotFoundError(ValueError):
class LokiPushApiError(Exception):
"""Base class for errors raised by this module."""


class RelationNotFoundError(LokiPushApiError):
"""Raised if there is no relation with the given name."""

def __init__(self, relation_name: str):
Expand All @@ -587,7 +610,7 @@ def __init__(self, relation_name: str):
super().__init__(self.message)


class RelationInterfaceMismatchError(Exception):
class RelationInterfaceMismatchError(LokiPushApiError):
"""Raised if the relation with the given name has a different interface."""

def __init__(
Expand All @@ -607,7 +630,7 @@ def __init__(
super().__init__(self.message)


class RelationRoleMismatchError(Exception):
class RelationRoleMismatchError(LokiPushApiError):
"""Raised if the relation with the given name has a different direction."""

def __init__(
Expand Down Expand Up @@ -2555,7 +2578,7 @@ def _on_pebble_ready(self, event: PebbleReadyEvent):

self._update_endpoints(event.workload, loki_endpoints)

def _update_logging(self, _):
def _update_logging(self, event: RelationEvent):
"""Update the log forwarding to match the active Loki endpoints."""
if not (loki_endpoints := self._retrieve_endpoints_from_relation()):
logger.warning("No Loki endpoints available")
Expand All @@ -2566,6 +2589,8 @@ def _update_logging(self, _):
self._update_endpoints(container, loki_endpoints)
# else: `_update_endpoints` will be called on pebble-ready anyway.

self._handle_alert_rules(event.relation)

def _retrieve_endpoints_from_relation(self) -> dict:
loki_endpoints = {}

Expand Down Expand Up @@ -2750,3 +2775,49 @@ def _exec(self, cmd) -> str:
result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE)
output = result.stdout.decode("utf-8").strip()
return output


def charm_logging_config(
endpoint_requirer: LokiPushApiConsumer, cert_path: Optional[Union[Path, str]]
) -> Tuple[Optional[List[str]], Optional[str]]:
"""Utility function to determine the charm_logging config you will likely want.

If no endpoint is provided:
disable charm logging.
If https endpoint is provided but cert_path is not found on disk:
disable charm logging.
If https endpoint is provided and cert_path is None:
ERROR
Else:
proceed with charm logging (with or without tls, as appropriate)

Args:
endpoint_requirer: an instance of LokiPushApiConsumer.
cert_path: a path where a cert is stored.

Returns:
A tuple with (optionally) the values of the endpoints and the certificate path.

Raises:
LokiPushApiError: if some endpoint are http and others https.
"""
endpoints = [ep["url"] for ep in endpoint_requirer.loki_endpoints]
if not endpoints:
return None, None

https = tuple(endpoint.startswith("https://") for endpoint in endpoints)

if all(https): # all endpoints are https
if cert_path is None:
raise LokiPushApiError("Cannot send logs to https endpoints without a certificate.")
if not Path(cert_path).exists():
# if endpoints is https BUT we don't have a server_cert yet:
# disable charm logging until we do to prevent tls errors
return None, None
return endpoints, str(cert_path)

if all(not x for x in https): # all endpoints are http
return endpoints, None

# if there's a disagreement, that's very weird:
raise LokiPushApiError("Some endpoints are http, some others are https. That's not good.")
24 changes: 21 additions & 3 deletions examples/django/charm/lib/charms/redis_k8s/v0/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

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

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -78,6 +78,18 @@ def _on_relation_broken(self, event):
# Trigger an event that our charm can react to.
self.charm.on.redis_relation_updated.emit()

@property
def app_data(self) -> Optional[Dict[str, str]]:
"""Retrieve the app data.

Returns:
Dict: dict containing the app data.
"""
relation = self.model.get_relation(self.relation_name)
if not relation:
return None
return relation.data[relation.app]

@property
def relation_data(self) -> Optional[Dict[str, str]]:
"""Retrieve the relation data.
Expand All @@ -98,10 +110,16 @@ def url(self) -> Optional[str]:
Returns:
str: the Redis URL.
"""
relation_data = self.relation_data
if not relation_data:
if not (relation_data := self.relation_data):
return None

redis_host = relation_data.get("hostname")

if app_data := self.app_data:
try:
redis_host = self.app_data.get("leader-host", redis_host)
except KeyError:
pass
redis_port = relation_data.get("port")
return f"redis://{redis_host}:{redis_port}"

Expand Down
27 changes: 18 additions & 9 deletions examples/django/charm/lib/charms/traefik_k8s/v2/ingress.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent):
import socket
import typing
from dataclasses import dataclass
from functools import partial
from typing import Any, Callable, Dict, List, MutableMapping, Optional, Sequence, Tuple, Union

import pydantic
from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent
from ops.framework import EventSource, Object, ObjectEvents, StoredState
from ops.model import ModelError, Relation, Unit
from pydantic import AnyHttpUrl, BaseModel, Field, validator
from pydantic import AnyHttpUrl, BaseModel, Field

# The unique Charmhub library identifier, never change it
LIBID = "e6de2a5cd5b34422a204668f3b8f90d2"
Expand All @@ -72,7 +73,7 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent):

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

PYDEPS = ["pydantic"]

Expand All @@ -84,6 +85,9 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent):

PYDANTIC_IS_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2
if PYDANTIC_IS_V1:
from pydantic import validator

input_validator = partial(validator, pre=True)

class DatabagModel(BaseModel): # type: ignore
"""Base databag model."""
Expand Down Expand Up @@ -143,7 +147,9 @@ def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True):
return databag

else:
from pydantic import ConfigDict
from pydantic import ConfigDict, field_validator

input_validator = partial(field_validator, mode="before")

class DatabagModel(BaseModel):
"""Base databag model."""
Expand Down Expand Up @@ -171,7 +177,7 @@ def load(cls, databag: MutableMapping):
k: json.loads(v)
for k, v in databag.items()
# Don't attempt to parse model-external values
if k in {(f.alias or n) for n, f in cls.__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:
msg = f"invalid databag contents: expecting json. {databag}"
Expand Down Expand Up @@ -252,14 +258,14 @@ class IngressRequirerAppData(DatabagModel):
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
"""Validate scheme arg."""
if scheme not in {"http", "https", "h2c"}:
raise ValueError("invalid scheme: should be one of `http|https|h2c`")
return scheme

@validator("port", pre=True)
@input_validator("port")
def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg
"""Validate port."""
assert isinstance(port, int), type(port)
Expand All @@ -277,13 +283,13 @@ class IngressRequirerUnitData(DatabagModel):
"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
"""Validate host."""
assert isinstance(host, str), type(host)
return host

@validator("ip", pre=True)
@input_validator("ip")
def validate_ip(cls, ip): # noqa: N805 # pydantic wants 'cls' as first arg
"""Validate ip."""
if ip is None:
Expand Down Expand Up @@ -462,7 +468,10 @@ def _handle_relation(self, event):
event.relation,
data.app.name,
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.redirect_https or False,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent):

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

PYDEPS = ["ops>=2.0.0"]

Expand Down Expand Up @@ -391,6 +391,10 @@ class IllegalOperationError(DataInterfacesError):
"""To be used when an operation is not allowed to be performed."""


class PrematureDataAccessError(DataInterfacesError):
"""To be raised when the Relation Data may be accessed (written) before protocol init complete."""


##############################################################################
# Global helpers / utilities
##############################################################################
Expand Down Expand Up @@ -1453,6 +1457,8 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
class ProviderData(Data):
"""Base provides-side of the data products relation."""

RESOURCE_FIELD = "database"

def __init__(
self,
model: Model,
Expand Down Expand Up @@ -1618,6 +1624,15 @@ def _fetch_my_specific_relation_data(
def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None:
"""Set values for fields not caring whether it's a secret or not."""
req_secret_fields = []

keys = set(data.keys())
if self.fetch_relation_field(relation.id, self.RESOURCE_FIELD) is None and (
keys - {"endpoints", "read-only-endpoints", "replset"}
):
raise PrematureDataAccessError(
"Premature access to relation data, update is forbidden before the connection is initialized."
)

if relation.app:
req_secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS)

Expand Down Expand Up @@ -3290,6 +3305,8 @@ class KafkaRequiresEvents(CharmEvents):
class KafkaProviderData(ProviderData):
"""Provider-side of the Kafka relation."""

RESOURCE_FIELD = "topic"

def __init__(self, model: Model, relation_name: str) -> None:
super().__init__(model, relation_name)

Expand Down Expand Up @@ -3539,6 +3556,8 @@ class OpenSearchRequiresEvents(CharmEvents):
class OpenSearchProvidesData(ProviderData):
"""Provider-side of the OpenSearch relation."""

RESOURCE_FIELD = "index"

def __init__(self, model: Model, relation_name: str) -> None:
super().__init__(model, relation_name)

Expand Down
Loading
Loading