Skip to content
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
40 changes: 33 additions & 7 deletions haproxy-operator/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def __init__(self, *args: typing.Any):
self.haproxy_route_provider.on.data_available,
self.haproxy_route_provider.on.data_removed,
],
mode=Mode.UNIT,
mode=Mode.APP,
)

self._tls = TLSRelationService(self.model, self.certificates, self.recv_ca_certs)
Expand Down Expand Up @@ -210,6 +210,9 @@ def __init__(self, *args: typing.Any):
self.framework.observe(
self.on[DDOS_PROTECTION_RELATION_NAME].relation_broken, self._on_config_changed
)
self.framework.observe(
self.on[HAPROXY_PEER_INTEGRATION].relation_changed, self._on_config_changed
)

@validate_config_and_tls(defer=False)
def _on_install(self, _: typing.Any) -> None:
Expand Down Expand Up @@ -292,22 +295,37 @@ def _reconcile(self) -> None:
self._configure_haproxy_route(charm_state, ha_information)
case _:
if self.model.get_relation(TLS_CERT_RELATION):
# Reconcile certificates in case the certificates relation is present
tls_information = TLSInformation.from_charm(self, self.certificates)
self._tls.certificate_available(tls_information)
if tls_information:
self._reconcile_certificates(tls_information)

self.unit.set_ports(80)
self.haproxy_service.reconcile_default(charm_state)
self.unit.status = ops.ActiveStatus()

def _reconcile_certificates(self, tls_information: TLSInformation) -> None:
"""Reconcile certificates: write to disk and share via peer relation if leader.

Args:
tls_information: TLSInformation charm state component.
"""
self._tls.certificate_available(tls_information)
if self.unit.is_leader():
peer_relation = self.model.get_relation(HAPROXY_PEER_INTEGRATION)
if peer_relation:
self._tls.share_certificates_via_peer_relation(peer_relation, tls_information)

def _configure_ingress(
self,
charm_state: CharmState,
requirer_class: type[IngressRequirersInformation | IngressPerUnitRequirersInformation],
) -> None:
"""Configure the ingress or ingress-per-unit relation."""
tls_information = TLSInformation.from_charm(self, self.certificates)
self._tls.certificate_available(tls_information)
if not tls_information:
logger.info("TLS information not available yet, skipping ingress configuration.")
return
self._reconcile_certificates(tls_information)

ingress_provider = (
self._ingress_provider
Expand All @@ -329,9 +347,9 @@ def _configure_ingress(
def _configure_legacy(self, charm_state: CharmState) -> None:
"""Configure the legacy mode."""
if self.model.get_relation(TLS_CERT_RELATION):
# Reconcile certificates in case the certificates relation is present
tls_information = TLSInformation.from_charm(self, self.certificates)
self._tls.certificate_available(tls_information)
if tls_information:
self._reconcile_certificates(tls_information)

legacy_invalid_requested_port: list[str] = []
required_ports: set[Port] = set()
Expand Down Expand Up @@ -378,7 +396,11 @@ def _configure_haproxy_route(
)
)
tls_information = TLSInformation.from_charm(self, self.certificates, allow_no_certificates)
self._tls.certificate_available(tls_information)
if not tls_information and not allow_no_certificates:
logger.info("TLS information not available yet, skipping haproxy-route configuration.")
return
if tls_information:
self._reconcile_certificates(tls_information)
ddos_protection_config = DDosProtection.from_charm(self.ddos_requirer)

spoe_oauth_info_list = SpoeAuthInformation.from_requirer(self.spoe_auth_requirer)
Expand Down Expand Up @@ -491,6 +513,8 @@ def _on_ingress_per_unit_data_provided(self, _: IngressDataReadyEvent) -> None:
self._reconcile()
if self.unit.is_leader():
tls_information = TLSInformation.from_charm(self, self.certificates)
if not tls_information:
return
for relation in self._ingress_per_unit_provider.relations:
for unit in relation.units:
if not self._ingress_per_unit_provider.is_unit_ready(relation, unit):
Expand All @@ -517,6 +541,8 @@ def _on_ingress_data_provided(self, event: IngressPerAppDataProvidedEvent) -> No
self._reconcile()
if self.unit.is_leader():
tls_information = TLSInformation.from_charm(self, self.certificates)
if not tls_information:
return
integration_data = self._ingress_provider.get_data(event.relation)
path_prefix = f"{integration_data.app.model}-{integration_data.app.name}"
self._ingress_provider.publish_url(
Expand Down
49 changes: 47 additions & 2 deletions haproxy-operator/src/state/tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

"""haproxy-operator charm tls information."""

import json
import logging
import typing
from dataclasses import dataclass

import ops
Expand All @@ -13,6 +15,10 @@
TLSCertificatesRequiresV4,
)

from .ha import HAPROXY_PEER_INTEGRATION

PEER_TLS_KEY = "tls_certificate_data"

logger = logging.getLogger()


Expand Down Expand Up @@ -47,9 +53,12 @@ def from_charm(
charm: ops.CharmBase,
certificates: TLSCertificatesRequiresV4,
allow_no_certificates: bool = False,
) -> "TLSInformation":
) -> typing.Optional["TLSInformation"]:
"""Get TLS information from a charm instance.

On leader units, reads certificates from the TLS library.
On non-leader units, reads certificates shared via the peer relation.

Args:
charm: The haproxy charm.
certificates: TLS certificates requirer class.
Expand All @@ -59,8 +68,11 @@ def from_charm(
PrivateKeyNotGeneratedError: When waiting for the private key to be generated.

Returns:
TLSInformation: Information about configured TLS certs.
TLSInformation if available, None if non-leader and peer data not yet available.
"""
if not charm.unit.is_leader():
return cls._from_peer_relation(charm)

cls.validate(charm, certificates, allow_no_certificates)

hostnames = [
Expand All @@ -86,6 +98,39 @@ def from_charm(
private_key=str(private_key),
)

@classmethod
def _from_peer_relation(cls, charm: ops.CharmBase) -> typing.Optional["TLSInformation"]:
"""Read TLS certificate data from the peer relation app databag.

Args:
charm: The haproxy charm.

Returns:
TLSInformation if available, None if the peer relation does not exist
or the certificate data has not yet been shared by the leader.
"""
peer_relation = charm.model.get_relation(HAPROXY_PEER_INTEGRATION)
if not peer_relation:
logger.info("Peer relation not available, cannot read TLS data.")
return None
raw = peer_relation.data[charm.app].get(PEER_TLS_KEY)
if not raw:
logger.info("No TLS certificate data in peer relation yet.")
return None
data = json.loads(raw)
hostnames = data["hostnames"]
private_key = data["private_key"]
tls_cert_and_ca_chain: dict[str, tuple[Certificate, list[Certificate]]] = {}
for hostname, cert_data in data["certificates"].items():
certificate = Certificate.from_string(cert_data["certificate"])
chain = [Certificate.from_string(c) for c in cert_data["chain"]]
tls_cert_and_ca_chain[hostname] = (certificate, chain)
return cls(
hostnames=hostnames,
tls_cert_and_ca_chain=tls_cert_and_ca_chain,
private_key=private_key,
)

# Validation is done in this method instead of using a pydantic model because
# there are cases where we need to validate the state but we don't need the state instance.
@classmethod
Expand Down
33 changes: 28 additions & 5 deletions haproxy-operator/src/tls_relation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# mypy guesses the relations might be None about all of them.
"""Haproxy TLS relation business logic."""

import json
import logging
import typing
from pathlib import Path
Expand All @@ -17,11 +18,11 @@
ProviderCertificate,
TLSCertificatesRequiresV4,
)
from ops.model import Model
from ops.model import Model, Relation

from haproxy import file_exists, read_file, render_file
from state.haproxy_route import HAPROXY_CAS_DIR, HAPROXY_CAS_FILE
from state.tls import TLSInformation
from state.tls import PEER_TLS_KEY, TLSInformation

TLS_CERT = "certificates"
HAPROXY_CERTS_DIR = Path("/var/lib/haproxy/certs")
Expand Down Expand Up @@ -80,9 +81,6 @@ def certificate_available(self, tls_information: TLSInformation) -> None:
Args:
tls_information: TLSInformation charm state component.
"""
if len(self.certificates.certificate_requests) == 0:
logger.warning("No certificate was requested")
return
for certificate, chain in tls_information.tls_cert_and_ca_chain.values():
if not self._certificate_matches_stored_content(
certificate=certificate,
Expand All @@ -95,6 +93,31 @@ def certificate_available(self, tls_information: TLSInformation) -> None:
private_key=tls_information.private_key,
)

def share_certificates_via_peer_relation(
self,
peer_relation: Relation,
tls_information: TLSInformation,
) -> None:
"""Share TLS certificate data with peer units via the peer relation app databag.

Args:
peer_relation: The haproxy-peers relation.
tls_information: TLSInformation charm state component.
"""
certificates_data: dict[str, dict[str, typing.Any]] = {}
for hostname, (certificate, chain) in tls_information.tls_cert_and_ca_chain.items():
certificates_data[hostname] = {
"certificate": str(certificate),
"chain": [str(cert) for cert in chain],
}
data = {
"hostnames": tls_information.hostnames,
"certificates": certificates_data,
"private_key": str(tls_information.private_key),
}
peer_relation.data[self.application][PEER_TLS_KEY] = json.dumps(data)
logger.info("Shared TLS certificate data via peer relation.")

def update_trusted_cas(self) -> None:
"""Handle the change in the set of CAs to trust."""
ca_certificates = self.recv_ca_cert.get_all_certificates()
Expand Down
5 changes: 4 additions & 1 deletion haproxy-operator/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def certificates_integration_fixture(certificates_relation_data, csr_certificate
return scenario.Relation(
endpoint="certificates",
remote_app_data=certificates_relation_data,
local_unit_data={
local_app_data={
"certificate_signing_requests": json.dumps(
[
{
Expand Down Expand Up @@ -312,6 +312,7 @@ def base_state_with_ingress_fixture(peer_relation, ingress_integration, certific
Yield: The modeled haproxy-peers relation.
"""
input_state = {
"leader": True,
"relations": [peer_relation, ingress_integration, certificates_integration],
"config": {
"external-hostname": "ingress.local",
Expand Down Expand Up @@ -345,6 +346,7 @@ def base_state_haproxy_route_fixture(
Yield: The modeled haproxy-peers relation.
"""
input_state = {
"leader": True,
"relations": [
peer_relation,
certificates_integration,
Expand Down Expand Up @@ -413,6 +415,7 @@ def base_state_with_ingress_per_unit_fixture(
Yield: The modeled haproxy-peers relation.
"""
input_state = {
"leader": True,
"relations": [peer_relation, ingress_per_unit_integration, certificates_integration],
"config": {
"external-hostname": "ingress.local",
Expand Down
Loading