Skip to content

Commit 5028ced

Browse files
Add refresh_events to CertHandler (#108)
* refresh events * static check * lint * nit + speed up tests * improve doc * revert init * refactor * use hashing
1 parent 47a4142 commit 5028ced

File tree

4 files changed

+154
-4
lines changed

4 files changed

+154
-4
lines changed

lib/charms/observability_libs/v1/cert_handler.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,15 @@
5959
import logging
6060

6161
from ops.charm import CharmBase
62-
from ops.framework import EventBase, EventSource, Object, ObjectEvents
62+
from ops.framework import BoundEvent, EventBase, EventSource, Object, ObjectEvents, StoredState
6363
from ops.jujuversion import JujuVersion
6464
from ops.model import Relation, Secret, SecretNotFoundError
6565

6666
logger = logging.getLogger(__name__)
6767

6868
LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a"
6969
LIBAPI = 1
70-
LIBPATCH = 11
70+
LIBPATCH = 12
7171

7272
VAULT_SECRET_LABEL = "cert-handler-private-vault"
7373

@@ -273,6 +273,7 @@ class CertHandler(Object):
273273
"""A wrapper for the requirer side of the TLS Certificates charm library."""
274274

275275
on = CertHandlerEvents() # pyright: ignore
276+
_stored = StoredState()
276277

277278
def __init__(
278279
self,
@@ -283,6 +284,7 @@ def __init__(
283284
peer_relation_name: str = "peers",
284285
cert_subject: Optional[str] = None,
285286
sans: Optional[List[str]] = None,
287+
refresh_events: Optional[List[BoundEvent]] = None,
286288
):
287289
"""CertHandler is used to wrap TLS Certificates management operations for charms.
288290
@@ -299,8 +301,17 @@ def __init__(
299301
Must match metadata.yaml.
300302
cert_subject: Custom subject. Name collisions are under the caller's responsibility.
301303
sans: DNS names. If none are given, use FQDN.
304+
refresh_events: an optional list of bound events which
305+
will be observed to replace the current CSR with a new one
306+
if there are changes in the CSR's DNS SANs or IP SANs.
307+
Then, subsequently, replace its corresponding certificate with a new one.
302308
"""
303309
super().__init__(charm, key)
310+
# use StoredState to store the hash of the CSR
311+
# to potentially trigger a CSR renewal on `refresh_events`
312+
self._stored.set_default(
313+
csr_hash=None,
314+
)
304315
self.charm = charm
305316

306317
# We need to sanitize the unit name, otherwise route53 complains:
@@ -355,6 +366,15 @@ def __init__(
355366
self._on_upgrade_charm,
356367
)
357368

369+
if refresh_events:
370+
for ev in refresh_events:
371+
self.framework.observe(ev, self._on_refresh_event)
372+
373+
def _on_refresh_event(self, _):
374+
"""Replace the latest current CSR with a new one if there are any SANs changes."""
375+
if self._stored.csr_hash != self._csr_hash:
376+
self._generate_csr(renew=True)
377+
358378
def _on_upgrade_charm(self, _):
359379
has_privkey = self.vault.get_value("private-key")
360380

@@ -419,6 +439,20 @@ def enabled(self) -> bool:
419439

420440
return True
421441

442+
@property
443+
def _csr_hash(self) -> int:
444+
"""A hash of the config that constructs the CSR.
445+
446+
Only include here the config options that, should they change, should trigger a renewal of
447+
the CSR.
448+
"""
449+
return hash(
450+
(
451+
tuple(self.sans_dns),
452+
tuple(self.sans_ip),
453+
)
454+
)
455+
422456
@property
423457
def available(self) -> bool:
424458
"""Return True if all certs are available in relation data; False otherwise."""
@@ -484,6 +518,8 @@ def _generate_csr(
484518
)
485519
self.certificates.request_certificate_creation(certificate_signing_request=csr)
486520

521+
self._stored.csr_hash = self._csr_hash
522+
487523
if clear_cert:
488524
self.vault.clear()
489525

tests/scenario/test_cert_handler/test_cert_handler_v1.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import socket
22
import sys
3+
from contextlib import contextmanager
34
from pathlib import Path
5+
from unittest.mock import patch
46

57
import pytest
8+
from cryptography import x509
9+
from cryptography.hazmat.backends import default_backend
10+
from cryptography.x509.oid import ExtensionOID
611
from ops import CharmBase
712
from scenario import Context, PeerRelation, Relation, State
813

@@ -12,6 +17,7 @@
1217

1318
libs = str(Path(__file__).parent.parent.parent.parent / "lib")
1419
sys.path.append(libs)
20+
MOCK_HOSTNAME = "mock-hostname"
1521

1622

1723
class MyCharm(CharmBase):
@@ -22,8 +28,28 @@ class MyCharm(CharmBase):
2228

2329
def __init__(self, fw):
2430
super().__init__(fw)
31+
sans = [socket.getfqdn()]
32+
if hostname := self._mock_san:
33+
sans.append(hostname)
2534

26-
self.ch = CertHandler(self, key="ch", sans=[socket.getfqdn()])
35+
self.ch = CertHandler(self, key="ch", sans=sans, refresh_events=[self.on.config_changed])
36+
37+
@property
38+
def _mock_san(self):
39+
"""This property is meant to be mocked to return a mock string hostname to be used as SAN.
40+
41+
By default, it returns None.
42+
"""
43+
return None
44+
45+
46+
def get_csr_obj(csr: str):
47+
return x509.load_pem_x509_csr(csr.encode(), default_backend())
48+
49+
50+
def get_sans_from_csr(csr):
51+
san_extension = csr.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
52+
return set(san_extension.value.get_values_for_type(x509.DNSName))
2753

2854

2955
@pytest.fixture
@@ -36,6 +62,20 @@ def certificates():
3662
return Relation("certificates")
3763

3864

65+
@contextmanager
66+
def _sans_patch(hostname=MOCK_HOSTNAME):
67+
with patch.object(MyCharm, "_mock_san", hostname):
68+
yield
69+
70+
71+
@contextmanager
72+
def _cert_renew_patch():
73+
with patch(
74+
"charms.tls_certificates_interface.v3.tls_certificates.TLSCertificatesRequiresV3.request_certificate_renewal"
75+
) as patcher:
76+
yield patcher
77+
78+
3979
@pytest.mark.parametrize("leader", (True, False))
4080
def test_cert_joins(ctx, certificates, leader):
4181
with ctx.manager(
@@ -72,3 +112,65 @@ def test_cert_joins_peer_vault_backend(ctx_juju2, certificates, leader):
72112
) as mgr:
73113
mgr.run()
74114
assert mgr.charm.ch.private_key
115+
116+
117+
def test_renew_csr_on_sans_change(ctx, certificates):
118+
# generate a CSR
119+
with ctx.manager(
120+
certificates.joined_event,
121+
State(leader=True, relations=[certificates]),
122+
) as mgr:
123+
charm = mgr.charm
124+
state_out = mgr.run()
125+
orig_csr = get_csr_obj(charm.ch._csr)
126+
assert get_sans_from_csr(orig_csr) == {socket.getfqdn()}
127+
128+
# trigger a config_changed with a modified SAN
129+
with _sans_patch():
130+
with ctx.manager("config_changed", state_out) as mgr:
131+
charm = mgr.charm
132+
state_out = mgr.run()
133+
csr = get_csr_obj(charm.ch._csr)
134+
# assert CSR contains updated SAN
135+
assert get_sans_from_csr(csr) == {socket.getfqdn(), MOCK_HOSTNAME}
136+
137+
138+
def test_csr_no_change_on_wrong_refresh_event(ctx, certificates):
139+
with _cert_renew_patch() as renew_patch:
140+
with ctx.manager(
141+
"config_changed",
142+
State(leader=True, relations=[certificates]),
143+
) as mgr:
144+
charm = mgr.charm
145+
state_out = mgr.run()
146+
orig_csr = get_csr_obj(charm.ch._csr)
147+
assert get_sans_from_csr(orig_csr) == {socket.getfqdn()}
148+
149+
with _sans_patch():
150+
with _cert_renew_patch() as renew_patch:
151+
with ctx.manager("update_status", state_out) as mgr:
152+
charm = mgr.charm
153+
state_out = mgr.run()
154+
csr = get_csr_obj(charm.ch._csr)
155+
assert get_sans_from_csr(csr) == {socket.getfqdn()}
156+
assert renew_patch.call_count == 0
157+
158+
159+
def test_csr_no_change(ctx, certificates):
160+
161+
with ctx.manager(
162+
"config_changed",
163+
State(leader=True, relations=[certificates]),
164+
) as mgr:
165+
charm = mgr.charm
166+
state_out = mgr.run()
167+
orig_csr = get_csr_obj(charm.ch._csr)
168+
assert get_sans_from_csr(orig_csr) == {socket.getfqdn()}
169+
170+
with _cert_renew_patch() as renew_patch:
171+
with ctx.manager("config_changed", state_out) as mgr:
172+
charm = mgr.charm
173+
state_out = mgr.run()
174+
csr = get_csr_obj(charm.ch._csr)
175+
assert get_sans_from_csr(csr) == {socket.getfqdn()}
176+
assert renew_patch.call_count == 0

tests/unit/test_kubernetes_compute_resources.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
# See LICENSE file for licensing details.
33
import unittest
44
from unittest import mock
5-
from unittest.mock import MagicMock, Mock
5+
from unittest.mock import MagicMock, Mock, patch
66

77
import httpx
8+
import tenacity
89
import yaml
910
from charms.observability_libs.v0.kubernetes_compute_resources_patch import (
1011
KubernetesComputeResourcesPatch,
@@ -16,12 +17,22 @@
1617
from ops import BlockedStatus, WaitingStatus
1718
from ops.charm import CharmBase
1819
from ops.testing import Harness
20+
from pytest import fixture
1921

2022
from tests.unit.helpers import PROJECT_DIR
2123

2224
CL_PATH = "charms.observability_libs.v0.kubernetes_compute_resources_patch.KubernetesComputeResourcesPatch"
2325

2426

27+
@fixture(autouse=True)
28+
def patch_retry():
29+
with patch.multiple(
30+
KubernetesComputeResourcesPatch,
31+
PATCH_RETRY_STOP=tenacity.stop_after_delay(0),
32+
):
33+
yield
34+
35+
2536
class TestKubernetesComputeResourcesPatch(unittest.TestCase):
2637
class _TestCharm(CharmBase):
2738
def __init__(self, *args):

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,6 @@ allowlist_externals =
111111
rm
112112
commands =
113113
charmcraft fetch-lib charms.tls_certificates_interface.v2.tls_certificates
114+
charmcraft fetch-lib charms.tls_certificates_interface.v3.tls_certificates
114115
pytest -v --tb native {[vars]tst_path}/scenario --log-cli-level=INFO -s {posargs}
115116
rm -rf ./lib/charms/tls_certificates_interface

0 commit comments

Comments
 (0)