Skip to content

Commit

Permalink
chore: update charm libraries (#49)
Browse files Browse the repository at this point in the history
Co-authored-by: Github Actions <github-actions@github.com>
  • Loading branch information
observability-noctua-bot and Github Actions authored Nov 24, 2023
1 parent b93e052 commit d72a852
Show file tree
Hide file tree
Showing 2 changed files with 89 additions and 34 deletions.
50 changes: 35 additions & 15 deletions charm/lib/charms/tls_certificates_interface/v2/tls_certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven
)
from ops.framework import EventBase, EventSource, Handle, Object
from ops.jujuversion import JujuVersion
from ops.model import Relation, SecretNotFoundError
from ops.model import ModelError, Relation, RelationDataContent, SecretNotFoundError

# The unique Charmhub library identifier, never change it
LIBID = "afd8c2bccf834997afce12c2706d2ede"
Expand All @@ -308,7 +308,7 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven

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

PYDEPS = ["cryptography", "jsonschema"]

Expand Down Expand Up @@ -600,23 +600,26 @@ def restore(self, snapshot: dict):
self.chain = snapshot["chain"]


def _load_relation_data(raw_relation_data: dict) -> dict:
def _load_relation_data(relation_data_content: RelationDataContent) -> dict:
"""Loads relation data from the relation data bag.
Json loads all data.
Args:
raw_relation_data: Relation data from the databag
relation_data_content: Relation data from the databag
Returns:
dict: Relation data in dict format.
"""
certificate_data = dict()
for key in raw_relation_data:
try:
certificate_data[key] = json.loads(raw_relation_data[key])
except (json.decoder.JSONDecodeError, TypeError):
certificate_data[key] = raw_relation_data[key]
try:
for key in relation_data_content:
try:
certificate_data[key] = json.loads(relation_data_content[key])
except (json.decoder.JSONDecodeError, TypeError):
certificate_data[key] = relation_data_content[key]
except ModelError:
pass
return certificate_data


Expand Down Expand Up @@ -1257,12 +1260,24 @@ def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None
)
self.remove_certificate(certificate=certificate["certificate"])

def get_requirer_csrs_with_no_certs(
def get_outstanding_certificate_requests(
self, relation_id: Optional[int] = None
) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]:
"""Filters the requirer's units csrs.
"""Returns CSR's for which no certificate has been issued.
Keeps the ones for which no certificate was provided.
Example return: [
{
"relation_id": 0,
"application_name": "tls-certificates-requirer",
"unit_name": "tls-certificates-requirer/0",
"unit_csrs": [
{
"certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...",
"is_ca": false
}
]
}
]
Args:
relation_id (int): Relation id
Expand All @@ -1279,6 +1294,7 @@ def get_requirer_csrs_with_no_certs(
if not self.certificate_issued_for_csr(
app_name=unit_csr_mapping["application_name"], # type: ignore[arg-type]
csr=csr["certificate_signing_request"], # type: ignore[index]
relation_id=relation_id,
):
csrs_without_certs.append(csr)
if csrs_without_certs:
Expand Down Expand Up @@ -1325,17 +1341,21 @@ def get_requirer_csrs(
)
return unit_csr_mappings

def certificate_issued_for_csr(self, app_name: str, csr: str) -> bool:
def certificate_issued_for_csr(
self, app_name: str, csr: str, relation_id: Optional[int]
) -> bool:
"""Checks whether a certificate has been issued for a given CSR.
Args:
app_name (str): Application name that the CSR belongs to.
csr (str): Certificate Signing Request.
relation_id (Optional[int]): Relation ID
Returns:
bool: True/False depending on whether a certificate has been issued for the given CSR.
"""
issued_certificates_per_csr = self.get_issued_certificates()[app_name]
issued_certificates_per_csr = self.get_issued_certificates(relation_id=relation_id)[
app_name
]
for issued_pair in issued_certificates_per_csr:
if "csr" in issued_pair and issued_pair["csr"] == csr:
return csr_matches_certificate(csr, issued_pair["certificate"])
Expand Down
73 changes: 54 additions & 19 deletions charm/lib/charms/traefik_k8s/v2/ingress.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,13 @@ def _on_ingress_ready(self, event: IngressPerAppReadyEvent):
def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent):
logger.info("This app no longer has ingress")
"""
import ipaddress
import json
import logging
import socket
import typing
from dataclasses import dataclass
from typing import (
Any,
Dict,
List,
MutableMapping,
Optional,
Sequence,
Tuple,
)
from typing import Any, Callable, Dict, List, MutableMapping, Optional, Sequence, Tuple, Union

import pydantic
from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent
Expand All @@ -79,7 +72,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 = 6
LIBPATCH = 8

PYDEPS = ["pydantic<2.0"]

Expand Down Expand Up @@ -200,14 +193,36 @@ def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg
class IngressRequirerUnitData(DatabagModel):
"""Ingress requirer unit databag model."""

host: str = Field(description="Hostname the unit wishes to be exposed.")
host: str = Field(description="Hostname at which the unit is reachable.")
ip: Optional[str] = Field(
description="IP at which the unit is reachable, "
"IP can only be None if the IP information can't be retrieved from juju."
)

@validator("host", pre=True)
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)
def validate_ip(cls, ip): # noqa: N805 # pydantic wants 'cls' as first arg
"""Validate ip."""
if ip is None:
return None
if not isinstance(ip, str):
raise TypeError(f"got ip of type {type(ip)} instead of expected str")
try:
ipaddress.IPv4Address(ip)
return ip
except ipaddress.AddressValueError:
pass
try:
ipaddress.IPv6Address(ip)
return ip
except ipaddress.AddressValueError:
raise ValueError(f"{ip!r} is not a valid ip address")


class RequirerSchema(BaseModel):
"""Requirer schema for Ingress."""
Expand Down Expand Up @@ -244,6 +259,7 @@ def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME)
observe(rel_events.relation_created, self._handle_relation)
observe(rel_events.relation_joined, self._handle_relation)
observe(rel_events.relation_changed, self._handle_relation)
observe(rel_events.relation_departed, self._handle_relation)
observe(rel_events.relation_broken, self._handle_relation_broken)
observe(charm.on.leader_elected, self._handle_upgrade_or_leader) # type: ignore
observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore
Expand Down Expand Up @@ -540,12 +556,13 @@ def __init__(
relation_name: str = DEFAULT_RELATION_NAME,
*,
host: Optional[str] = None,
ip: Optional[str] = None,
port: Optional[int] = None,
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",
scheme: Union[Callable[[], str], str] = lambda: "http",
):
"""Constructor for IngressRequirer.
Expand All @@ -560,9 +577,12 @@ def __init__(
relation must be of interface type `ingress` and have "limit: 1")
host: Hostname to be used by the ingress provider to address the requiring
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;
if unspecified, binding address from juju network API will be used.
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.
Or a string, if the scheme is known and stable at charm-init-time.
Request Args:
port: the port of the service
Expand All @@ -572,14 +592,14 @@ def __init__(
self.relation_name = relation_name
self._strip_prefix = strip_prefix
self._redirect_https = redirect_https
self._get_scheme = scheme
self._get_scheme = scheme if callable(scheme) else lambda: scheme

self._stored.set_default(current_url=None) # type: ignore

# if instantiated with a port, and we are related, then
# we immediately publish our ingress data to speed up the process.
if port:
self._auto_data = host, port
self._auto_data = host, ip, port
else:
self._auto_data = None

Expand Down Expand Up @@ -616,14 +636,15 @@ def is_ready(self):

def _publish_auto_data(self):
if self._auto_data:
host, port = self._auto_data
self.provide_ingress_requirements(host=host, port=port)
host, ip, port = self._auto_data
self.provide_ingress_requirements(host=host, ip=ip, port=port)

def provide_ingress_requirements(
self,
*,
scheme: Optional[str] = None,
host: Optional[str] = None,
ip: Optional[str] = None,
port: int,
):
"""Publishes the data that Traefik needs to provide ingress.
Expand All @@ -632,34 +653,48 @@ def provide_ingress_requirements(
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
requirer unit; if unspecified, FQDN will be used instead
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.
port: the port of the service (required)
"""
for relation in self.relations:
self._provide_ingress_requirements(scheme, host, port, relation)
self._provide_ingress_requirements(scheme, host, ip, port, relation)

def _provide_ingress_requirements(
self,
scheme: Optional[str],
host: Optional[str],
ip: Optional[str],
port: int,
relation: Relation,
):
if self.unit.is_leader():
self._publish_app_data(scheme, port, relation)

self._publish_unit_data(host, relation)
self._publish_unit_data(host, ip, relation)

def _publish_unit_data(
self,
host: Optional[str],
ip: Optional[str],
relation: Relation,
):
if not host:
host = socket.getfqdn()

if ip is None:
network_binding = self.charm.model.get_binding(relation)
if (
network_binding is not None
and (bind_address := network_binding.network.bind_address) is not None
):
ip = str(bind_address)
else:
log.error("failed to retrieve ip information from juju")

unit_databag = relation.data[self.unit]
try:
IngressRequirerUnitData(host=host).dump(unit_databag)
IngressRequirerUnitData(host=host, ip=ip).dump(unit_databag)
except pydantic.ValidationError as e:
msg = "failed to validate unit data"
log.info(msg, exc_info=True) # log to INFO because this might be expected
Expand Down

0 comments on commit d72a852

Please sign in to comment.