From d72a852b0b87a30955ced5299df1aaccb5693444 Mon Sep 17 00:00:00 2001 From: Noctua Date: Fri, 24 Nov 2023 13:03:21 +0100 Subject: [PATCH] chore: update charm libraries (#49) Co-authored-by: Github Actions --- .../v2/tls_certificates.py | 50 +++++++++---- charm/lib/charms/traefik_k8s/v2/ingress.py | 73 ++++++++++++++----- 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/charm/lib/charms/tls_certificates_interface/v2/tls_certificates.py b/charm/lib/charms/tls_certificates_interface/v2/tls_certificates.py index 09a7443..99741f5 100644 --- a/charm/lib/charms/tls_certificates_interface/v2/tls_certificates.py +++ b/charm/lib/charms/tls_certificates_interface/v2/tls_certificates.py @@ -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" @@ -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"] @@ -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 @@ -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 @@ -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: @@ -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"]) diff --git a/charm/lib/charms/traefik_k8s/v2/ingress.py b/charm/lib/charms/traefik_k8s/v2/ingress.py index 0364c8a..31028e9 100644 --- a/charm/lib/charms/traefik_k8s/v2/ingress.py +++ b/charm/lib/charms/traefik_k8s/v2/ingress.py @@ -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 @@ -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"] @@ -200,7 +193,11 @@ 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 @@ -208,6 +205,24 @@ def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg 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.""" @@ -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 @@ -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. @@ -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 @@ -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 @@ -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. @@ -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