Skip to content

Commit 00f61db

Browse files
Refresh CSR "whenever" needed (#110)
* refresh csr if needed * use stable hash * static * sort sans * lint * comments
1 parent b12d143 commit 00f61db

File tree

2 files changed

+72
-46
lines changed

2 files changed

+72
-46
lines changed

lib/charms/observability_libs/v1/cert_handler.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
Since this library uses [Juju Secrets](https://juju.is/docs/juju/secret) it requires Juju >= 3.0.3.
3333
"""
3434
import abc
35+
import hashlib
3536
import ipaddress
3637
import json
3738
import socket
@@ -67,7 +68,7 @@
6768

6869
LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a"
6970
LIBAPI = 1
70-
LIBPATCH = 13
71+
LIBPATCH = 14
7172

7273
VAULT_SECRET_LABEL = "cert-handler-private-vault"
7374

@@ -301,14 +302,11 @@ def __init__(
301302
Must match metadata.yaml.
302303
cert_subject: Custom subject. Name collisions are under the caller's responsibility.
303304
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.
305+
refresh_events: [DEPRECATED].
308306
"""
309307
super().__init__(charm, key)
310308
# use StoredState to store the hash of the CSR
311-
# to potentially trigger a CSR renewal on `refresh_events`
309+
# to potentially trigger a CSR renewal
312310
self._stored.set_default(
313311
csr_hash=None,
314312
)
@@ -320,8 +318,9 @@ def __init__(
320318

321319
# Use fqdn only if no SANs were given, and drop empty/duplicate SANs
322320
sans = list(set(filter(None, (sans or [socket.getfqdn()]))))
323-
self.sans_ip = list(filter(is_ip_address, sans))
324-
self.sans_dns = list(filterfalse(is_ip_address, sans))
321+
# sort SANS lists to avoid unnecessary csr renewals during reconciliation
322+
self.sans_ip = sorted(filter(is_ip_address, sans))
323+
self.sans_dns = sorted(filterfalse(is_ip_address, sans))
325324

326325
if self._check_juju_supports_secrets():
327326
vault_backend = _SecretVaultBackend(charm, secret_label=VAULT_SECRET_LABEL)
@@ -367,13 +366,15 @@ def __init__(
367366
)
368367

369368
if refresh_events:
370-
for ev in refresh_events:
371-
self.framework.observe(ev, self._on_refresh_event)
369+
logger.warn(
370+
"DEPRECATION WARNING. `refresh_events` is now deprecated. CertHandler will automatically refresh the CSR when necessary."
371+
)
372372

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)
373+
self._reconcile()
374+
375+
def _reconcile(self):
376+
"""Run all logic that is independent of what event we're processing."""
377+
self._refresh_csr_if_needed()
377378

378379
def _on_upgrade_charm(self, _):
379380
has_privkey = self.vault.get_value("private-key")
@@ -388,6 +389,11 @@ def _on_upgrade_charm(self, _):
388389
# this will call `self.private_key` which will generate a new privkey.
389390
self._generate_csr(renew=True)
390391

392+
def _refresh_csr_if_needed(self):
393+
"""Refresh the current CSR with a new one if there are any SANs changes."""
394+
if self._stored.csr_hash is not None and self._stored.csr_hash != self._csr_hash:
395+
self._generate_csr(renew=True)
396+
391397
def _migrate_vault(self):
392398
peer_backend = _RelationVaultBackend(self.charm, relation_name="peers")
393399

@@ -440,13 +446,17 @@ def enabled(self) -> bool:
440446
return True
441447

442448
@property
443-
def _csr_hash(self) -> int:
449+
def _csr_hash(self) -> str:
444450
"""A hash of the config that constructs the CSR.
445451
446452
Only include here the config options that, should they change, should trigger a renewal of
447453
the CSR.
448454
"""
449-
return hash(
455+
456+
def _stable_hash(data):
457+
return hashlib.sha256(str(data).encode()).hexdigest()
458+
459+
return _stable_hash(
450460
(
451461
tuple(self.sans_dns),
452462
tuple(self.sans_ip),

tests/scenario/test_cert_handler/test_cert_handler_v1.py

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55
from contextlib import contextmanager
66
from pathlib import Path
7-
from unittest.mock import patch
7+
from unittest.mock import MagicMock, patch
88

99
import pytest
1010
from cryptography import x509
@@ -36,7 +36,7 @@ def __init__(self, fw):
3636
if hostname := self._mock_san:
3737
sans.append(hostname)
3838

39-
self.ch = CertHandler(self, key="ch", sans=sans, refresh_events=[self.on.config_changed])
39+
self.ch = CertHandler(self, key="ch", sans=sans)
4040

4141
@property
4242
def _mock_san(self):
@@ -145,6 +145,14 @@ def _cert_renew_patch():
145145
yield patcher
146146

147147

148+
@contextmanager
149+
def _cert_generate_patch():
150+
with patch(
151+
"charms.tls_certificates_interface.v3.tls_certificates.TLSCertificatesRequiresV3.request_certificate_creation"
152+
) as patcher:
153+
yield patcher
154+
155+
148156
@pytest.mark.parametrize("leader", (True, False))
149157
def test_cert_joins(ctx, certificates, leader):
150158
with ctx.manager(
@@ -183,48 +191,56 @@ def test_cert_joins_peer_vault_backend(ctx_juju2, certificates, leader):
183191
assert mgr.charm.ch.private_key
184192

185193

186-
def test_renew_csr_on_sans_change(ctx, certificates):
187-
# generate a CSR
194+
# CertHandler generates a cert on `config_changed` event
195+
@pytest.mark.parametrize(
196+
"event,expected_generate_calls",
197+
(("update_status", 0), ("start", 0), ("install", 0), ("config_changed", 1)),
198+
)
199+
def test_no_renew_if_no_initial_csr_was_generated(
200+
event, expected_generate_calls, ctx, certificates
201+
):
202+
with _cert_renew_patch() as renew_patch:
203+
with _cert_generate_patch() as generate_patch:
204+
with ctx.manager(
205+
event,
206+
State(leader=True, relations=[certificates]),
207+
) as mgr:
208+
209+
mgr.run()
210+
assert renew_patch.call_count == 0
211+
assert generate_patch.call_count == expected_generate_calls
212+
213+
214+
@patch.object(CertHandler, "_stored", MagicMock())
215+
@pytest.mark.parametrize(
216+
"is_relation, event",
217+
(
218+
(False, "start"),
219+
(True, "changed_event"),
220+
(False, "config_changed"),
221+
),
222+
)
223+
def test_csr_renew_on_any_event(is_relation, event, ctx, certificates):
188224
with ctx.manager(
189-
certificates.joined_event,
190-
State(leader=True, relations=[certificates]),
225+
getattr(certificates, event) if is_relation else event,
226+
State(
227+
leader=True,
228+
relations=[certificates],
229+
),
191230
) as mgr:
192231
charm = mgr.charm
193232
state_out = mgr.run()
194233
orig_csr = get_csr_obj(charm.ch._csr)
195234
assert get_sans_from_csr(orig_csr) == {socket.getfqdn()}
196235

197-
# trigger a config_changed with a modified SAN
198236
with _sans_patch():
199-
with ctx.manager("config_changed", state_out) as mgr:
237+
with ctx.manager("update_status", state_out) as mgr:
200238
charm = mgr.charm
201239
state_out = mgr.run()
202240
csr = get_csr_obj(charm.ch._csr)
203-
# assert CSR contains updated SAN
204241
assert get_sans_from_csr(csr) == {socket.getfqdn(), MOCK_HOSTNAME}
205242

206243

207-
def test_csr_no_change_on_wrong_refresh_event(ctx, certificates):
208-
with _cert_renew_patch() as renew_patch:
209-
with ctx.manager(
210-
"config_changed",
211-
State(leader=True, relations=[certificates]),
212-
) as mgr:
213-
charm = mgr.charm
214-
state_out = mgr.run()
215-
orig_csr = get_csr_obj(charm.ch._csr)
216-
assert get_sans_from_csr(orig_csr) == {socket.getfqdn()}
217-
218-
with _sans_patch():
219-
with _cert_renew_patch() as renew_patch:
220-
with ctx.manager("update_status", state_out) as mgr:
221-
charm = mgr.charm
222-
state_out = mgr.run()
223-
csr = get_csr_obj(charm.ch._csr)
224-
assert get_sans_from_csr(csr) == {socket.getfqdn()}
225-
assert renew_patch.call_count == 0
226-
227-
228244
def test_csr_no_change(ctx, certificates):
229245

230246
with ctx.manager(

0 commit comments

Comments
 (0)