From 78492e2668c6887571caa6ccbefc6e696aeddf7b Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 19 Mar 2026 08:40:05 +0100 Subject: [PATCH 1/5] add config option for certificate-extra-sans --- config.yaml | 8 ++ poetry.lock | 19 ++++- pyproject.toml | 1 + src/events/tls.py | 14 ++++ src/managers/tls.py | 113 ++++++++++++++++++++++++++ src/statuses.py | 5 ++ tests/unit/test_tls.py | 178 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 336 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index 9307f3b..7ad52ee 100644 --- a/config.yaml +++ b/config.yaml @@ -16,3 +16,11 @@ options: - charmed-sentinel-operator: administrative user for operating valkey sentinel - charmed-sentinel-valkey: used by sentinel to authenticate against valkey - charmed-sentinel-peers: used by sentinel for inter-sentinel authentication + + certificate-extra-sans: + type: string + description: | + Config option to add extra-sans to the ones used when requesting server certificates. + The extra-sans are specified by comma-separated names to be added when requesting signed certificates. + Use "{unit}" as a placeholder to be filled with the unit number, e.g. "worker-{unit}" will be translated as + "worker-0" for unit 0 and "worker-1" for unit 1 when requesting the certificate. diff --git a/poetry.lock b/poetry.lock index a06763d..6d9362f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "allure-pytest" @@ -1169,6 +1169,21 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "validators" +version = "0.35.0" +description = "Python Data Validation for Humans™" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd"}, + {file = "validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a"}, +] + +[package.extras] +crypto-eth-addresses = ["eth-hash[pycryptodome] (>=0.7.0)"] + [[package]] name = "valkey-glide" version = "0.0.0" @@ -1231,4 +1246,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "3575fae932d789fe98e4d4bb5272cd80b6122121980ed4ef31538f9f8534b986" +content-hash = "4931f5cb51e859d1d886eab0911906e46f74a667ccbe8eef025dc682bd4d726d" diff --git a/pyproject.toml b/pyproject.toml index 9977a14..1a0beaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ charmlibs-snap = "^1.0.1" charmlibs-interfaces-tls-certificates = ">=1.6.0" tenacity = "*" data-platform-helpers = ">=0.1.7" +validators = ">=0.35.0" [tool.poetry.requires-plugins] poetry-plugin-export = ">=1.8" diff --git a/src/events/tls.py b/src/events/tls.py index 845bd95..1e63e8c 100644 --- a/src/events/tls.py +++ b/src/events/tls.py @@ -78,6 +78,7 @@ def __init__(self, charm: "ValkeyCharm"): self.charm.on[PEER_RELATION].relation_changed, self._on_peer_relation_changed ) self.framework.observe(self.charm.on.update_status, self._on_update_status) + self.framework.observe(self.charm.on.config_changed, self._on_config_changed) def _on_peer_relation_created(self, event: ops.RelationCreatedEvent) -> None: """Set up self-signed certificates for peer TLS by default.""" @@ -282,6 +283,19 @@ def _on_update_status(self, event: ops.UpdateStatusEvent) -> None: logger.debug("Trigger peer relation change to orchestrate certificate/CA rotation") self.charm.on[PEER_RELATION].relation_changed.emit(self.charm.state.peer_relation) + def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: + """Handle TLS related config changes.""" + if not self.charm.tls_manager.extra_sans_config_is_valid(): + logger.warning("Invalid configuration for 'certificate-extra-sans'") + return + + if ( + self.charm.state.client_tls_relation + and self.charm.tls_manager.certificate_sans_require_update() + ): + logger.info("Configuration change for TLS, refresh TLS certificates") + self.refresh_tls_certificates_event.emit() + def _enable_client_tls(self) -> None: """Check preconditions and enable TLS if possible.""" if not all(server.model.client_cert_ready for server in self.charm.state.servers): diff --git a/src/managers/tls.py b/src/managers/tls.py index 13ce3af..14385fd 100644 --- a/src/managers/tls.py +++ b/src/managers/tls.py @@ -6,6 +6,7 @@ import logging from datetime import timedelta +from ipaddress import ip_address from charmlibs.interfaces.tls_certificates import ( Certificate, @@ -17,6 +18,7 @@ from data_platform_helpers.advanced_statuses.models import StatusObject from data_platform_helpers.advanced_statuses.protocol import ManagerStatusProtocol from data_platform_helpers.advanced_statuses.types import Scope +from validators import ValidationError, hostname from common.exceptions import ValkeyWorkloadCommandError from core.base_workload import WorkloadBase @@ -94,10 +96,17 @@ def build_sans_ip(self) -> frozenset[str]: if not self.state.peer_relation: return frozenset(sans_ip) + if self.extra_sans_config_is_valid() and ( + extra_sans_config := self.state.config.get("certificate-extra-sans") + ): + extra_sans = [san.strip() for san in extra_sans_config.split(",")] + sans_ip = {san for san in extra_sans if self._is_ip_address(san)} + sans_ip.add(self.state.bind_address) if ingress_ip := self.state.ingress_address: sans_ip.add(ingress_ip) + logger.info(sans_ip) return frozenset(sans_ip) @@ -113,7 +122,18 @@ def build_sans_dns(self) -> frozenset[str]: if not self.state.peer_relation: return frozenset(sans_dns) + if self.extra_sans_config_is_valid() and ( + extra_sans_config := self.state.config.get("certificate-extra-sans") + ): + extra_sans = [san.strip() for san in extra_sans_config.split(",")] + sans_dns = { + san.replace("{unit}", str(self.state.unit_server.unit_id)) + for san in extra_sans + if not self._is_ip_address(san) + } + sans_dns.add(self.state.unit_server.model.hostname) + logger.info(sans_dns) return frozenset(sans_dns) @@ -246,6 +266,96 @@ def start_ca_rotation_if_required( ) return True + def extra_sans_config_is_valid(self) -> bool: + """Validate configuration value for certificate-extra-sans option. + + Returns: + bool: True if config value is valid, False if invalid. + """ + if not (extra_sans_config := self.state.config.get("certificate-extra-sans")): + return True + + extra_sans = [san.strip() for san in extra_sans_config.split(",")] + + for san in extra_sans: + if not self._is_ip_address(san): + if not self._is_hostname( + san.replace("{unit}", str(self.state.unit_server.unit_id)) + ): + logger.error(f"certificate-extra-sans configuration is invalid for {san}") + return False + + return True + + def _is_ip_address(self, input_value: str) -> bool: + """Validate a given str and return True if it is an IP address, False if not.""" + try: + ip_address(input_value) + return True + except ValueError: + return False + + def _is_hostname(self, input_value: str) -> bool: + """Validate a given str and return True if it is a hostname, False if not.""" + try: + # Hostname string may only be hyphens and alpha-numerals. + return hostname( + input_value, + skip_ipv4_addr=True, + skip_ipv6_addr=True, + may_have_port=False, + maybe_simple=True, + ) + except ValidationError: + return False + + def get_current_sans(self) -> dict[str, set[str]]: + """Get the current SANs for a unit's cert.""" + cert_file = self.workload.tls_paths.client_cert + + sans_ip = set() + sans_dns = set() + if not ( + san_lines := self.workload.exec( + [ + "openssl", + "x509", + "-ext", + "subjectAltName", + "-noout", + "-in", + cert_file.as_posix(), + ] + )[0].splitlines() + ): + return {"sans_ip": sans_ip, "sans_dns": sans_dns} + + for line in san_lines: + for sans in line.split(", "): + san_type, san_value = sans.split(":") + + if san_type.strip() == "DNS": + sans_dns.add(san_value) + if san_type.strip() == "IP Address": + sans_ip.add(san_value) + + return {"sans_ip": sans_ip, "sans_dns": sans_dns} + + def certificate_sans_require_update(self) -> bool: + """Check current certificate sans and determine if certificate requires update. + + Returns: + bool: True if certificate sans have changed, False if they are still the same. + """ + current_sans = self.get_current_sans() + new_sans_ip = self.build_sans_ip() + new_sans_dns = self.build_sans_dns() + + if new_sans_ip ^ current_sans["sans_ip"] or new_sans_dns ^ current_sans["sans_dns"]: + return True + + return False + def get_statuses(self, scope: Scope, recompute: bool = False) -> list[StatusObject]: """Compute the TLS statuses.""" status_list: list[StatusObject] = [] @@ -278,4 +388,7 @@ def get_statuses(self, scope: Scope, recompute: bool = False) -> list[StatusObje if self.state.unit_server.model.tls_certificate_expiring: status_list.append(TLSStatuses.CERTIFICATE_EXPIRING.value) + if not self.extra_sans_config_is_valid(): + status_list.append(TLSStatuses.SANS_CONFIG_INVALID.value) + return status_list if status_list else [CharmStatuses.ACTIVE_IDLE.value] diff --git a/src/statuses.py b/src/statuses.py index 66c7e87..de09a2f 100644 --- a/src/statuses.py +++ b/src/statuses.py @@ -116,3 +116,8 @@ class TLSStatuses(Enum): CA_ROTATION_UPDATED = StatusObject( status="maintenance", message="TLS CA rotation: certificates updated" ) + SANS_CONFIG_INVALID = StatusObject( + status="blocked", + message="Invalid value for config option 'certificate-extra-sans'", + short_message="Invalid value `certificate-extra-sans`", + ) diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index 2f49a08..5dd316c 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -927,3 +927,181 @@ def test_ca_rotation_all_units_ca_updated(cloud_spec): state_out.get_relation(1).local_unit_data.get("tls-ca-rotation") == TLSCARotationState.NO_ROTATION.value ) + + +def test_set_extra_sans_config_option(cloud_spec): + ctx = testing.Context(ValkeyCharm, app_trusted=True) + peer_relation = testing.PeerRelation( + id=1, + endpoint=PEER_RELATION, + local_unit_data={"start-state": "started", "tls-client-state": "tls"}, + ) + status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) + client_tls_relation = testing.Relation( + id=3, + endpoint=CLIENT_TLS_RELATION_NAME, + ) + container = testing.Container(name=CONTAINER, can_connect=True) + + state_in = testing.State( + relations={peer_relation, status_peer_relation, client_tls_relation}, + config={"certificate-extra-sans": "192.168.1.100, myhostname"}, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + current_sans_value = ( + "X509v3 Subject Alternative Name: \n " + "DNS:valkey-0.valkey-endpoints, " + "IP Address:127.1.1.1, IP Address:192.0.2.0" + ) + with ( + patch("workload_k8s.ValkeyK8sWorkload.exec", return_value=[current_sans_value]), + ): + ctx.run(ctx.on.config_changed(), state_in) + assert ctx.emitted_events[1].handle.kind == "refresh_tls_certificates_event" + + +def test_set_extra_sans_config_option_unit_placeholder(cloud_spec): + ctx = testing.Context(ValkeyCharm, app_trusted=True) + peer_relation = testing.PeerRelation( + id=1, + endpoint=PEER_RELATION, + local_unit_data={"start-state": "started", "tls-client-state": "tls"}, + ) + status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) + client_tls_relation = testing.Relation( + id=3, + endpoint=CLIENT_TLS_RELATION_NAME, + ) + container = testing.Container(name=CONTAINER, can_connect=True) + state_in = testing.State( + relations={peer_relation, status_peer_relation, client_tls_relation}, + config={ + "certificate-extra-sans": "192.168.1.100, valkey-{unit}.hostname", + }, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + current_sans_value = ( + "X509v3 Subject Alternative Name: \n " + "DNS:myhostname, DNS:valkey-0.valkey-endpoints, " + "IP Address:127.1.1.1, IP Address:192.168.1.100, IP Address:192.0.2.0" + ) + with ( + patch("workload_k8s.ValkeyK8sWorkload.exec", return_value=[current_sans_value]), + ): + ctx.run(ctx.on.config_changed(), state_in) + assert ctx.emitted_events[1].handle.kind == "refresh_tls_certificates_event" + + +def test_set_extra_sans_config_option_invalid_ip(cloud_spec): + ctx = testing.Context(ValkeyCharm, app_trusted=True) + peer_relation = testing.PeerRelation( + id=1, + endpoint=PEER_RELATION, + local_unit_data={"start-state": "started", "tls-client-state": "tls"}, + ) + status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) + client_tls_relation = testing.Relation( + id=3, + endpoint=CLIENT_TLS_RELATION_NAME, + ) + container = testing.Container(name=CONTAINER, can_connect=True) + state_in = testing.State( + relations={peer_relation, status_peer_relation, client_tls_relation}, + config={"certificate-extra-sans": "192.168.257.100, myhostname"}, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + state_out = ctx.run(ctx.on.config_changed(), state_in) + assert status_is(state_out, TLSStatuses.SANS_CONFIG_INVALID.value) + # no RefreshTLSCertificatesEvent must be emitted + assert len(ctx.emitted_events) == 1 + + +def test_set_extra_sans_config_option_invalid_dns(cloud_spec): + ctx = testing.Context(ValkeyCharm, app_trusted=True) + peer_relation = testing.PeerRelation( + id=1, + endpoint=PEER_RELATION, + local_unit_data={"start-state": "started", "tls-client-state": "tls"}, + ) + status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) + client_tls_relation = testing.Relation( + id=3, + endpoint=CLIENT_TLS_RELATION_NAME, + ) + container = testing.Container(name=CONTAINER, can_connect=True) + state_in = testing.State( + relations={peer_relation, status_peer_relation, client_tls_relation}, + config={"certificate-extra-sans": "-myhostname"}, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + state_out = ctx.run(ctx.on.config_changed(), state_in) + assert status_is(state_out, TLSStatuses.SANS_CONFIG_INVALID.value) + # no RefreshTLSCertificatesEvent must be emitted + assert len(ctx.emitted_events) == 1 + + +def test_set_extra_sans_config_option_special_chars(cloud_spec): + ctx = testing.Context(ValkeyCharm, app_trusted=True) + peer_relation = testing.PeerRelation( + id=1, + endpoint=PEER_RELATION, + local_unit_data={"start-state": "started", "tls-client-state": "tls"}, + ) + status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) + client_tls_relation = testing.Relation( + id=3, + endpoint=CLIENT_TLS_RELATION_NAME, + ) + container = testing.Container(name=CONTAINER, can_connect=True) + state_in = testing.State( + relations={peer_relation, status_peer_relation, client_tls_relation}, + config={"certificate-extra-sans": "192.168.1.100, my$*hostname"}, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + state_out = ctx.run(ctx.on.config_changed(), state_in) + assert status_is(state_out, TLSStatuses.SANS_CONFIG_INVALID.value) + # no RefreshTLSCertificatesEvent must be emitted + assert len(ctx.emitted_events) == 1 + + +def test_set_extra_sans_config_option_no_update(cloud_spec): + ctx = testing.Context(ValkeyCharm, app_trusted=True) + peer_relation = testing.PeerRelation( + id=1, + endpoint=PEER_RELATION, + local_unit_data={"start-state": "started", "tls-client-state": "tls"}, + ) + status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) + client_tls_relation = testing.Relation( + id=3, + endpoint=CLIENT_TLS_RELATION_NAME, + ) + container = testing.Container(name=CONTAINER, can_connect=True) + state_in = testing.State( + relations={peer_relation, status_peer_relation, client_tls_relation}, + config={"certificate-extra-sans": "192.168.1.100, myhostname"}, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + current_sans_value = ( + "X509v3 Subject Alternative Name: \n " + "DNS:myhostname, DNS:valkey-0.valkey-endpoints, " + "IP Address:127.1.1.1, IP Address:192.168.1.100, IP Address:192.0.2.0" + ) + with ( + patch("workload_k8s.ValkeyK8sWorkload.exec", return_value=[current_sans_value]), + ): + ctx.run(ctx.on.config_changed(), state_in) + # no RefreshTLSCertificatesEvent must be emitted + assert len(ctx.emitted_events) == 1 From 7ac0a4e4bc272afc276aa136cc9f16e78a1956c3 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 19 Mar 2026 09:26:58 +0100 Subject: [PATCH 2/5] add integration test --- .../tls/test_certificate_options.py | 100 ++++++++++++++++++ .../k8s/test_certificate_options.py/task.yaml | 7 ++ .../vm/test_certificate_options.py/task.yaml | 7 ++ 3 files changed, 114 insertions(+) create mode 100644 tests/integration/tls/test_certificate_options.py create mode 100644 tests/spread/k8s/test_certificate_options.py/task.yaml create mode 100644 tests/spread/vm/test_certificate_options.py/task.yaml diff --git a/tests/integration/tls/test_certificate_options.py b/tests/integration/tls/test_certificate_options.py new file mode 100644 index 0000000..ce3da6f --- /dev/null +++ b/tests/integration/tls/test_certificate_options.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +import logging +import subprocess + +import jubilant + +from literals import Substrate +from statuses import TLSStatuses +from tests.integration.helpers import ( + APP_NAME, + IMAGE_RESOURCE, + TLS_CERT_FILE, + TLS_CHANNEL, + TLS_NAME, + are_agents_idle, + are_apps_active_and_agents_idle, + does_status_match, + download_client_certificate_from_unit, +) + +logger = logging.getLogger(__name__) + +NUM_UNITS = 3 + + +def test_build_and_deploy(charm: str, juju: jubilant.Juju, substrate: Substrate) -> None: + """Deploy the charm under test and a TLS provider.""" + juju.deploy( + charm, + resources=IMAGE_RESOURCE if substrate == Substrate.K8S else None, + num_units=NUM_UNITS, + trust=True, + ) + juju.deploy(TLS_NAME, channel=TLS_CHANNEL) + juju.integrate(f"{APP_NAME}:client-certificates", TLS_NAME) + juju.wait( + lambda status: are_agents_idle(status, APP_NAME, idle_period=30, unit_count=NUM_UNITS), + timeout=600, + ) + + +def test_extra_sans_config_option(juju: jubilant.Juju) -> None: + """Configure extra sans for the TLS certificates.""" + logger.info("Set config to invalid sans value") + config_value = "-my.hostname" + juju.config(app=APP_NAME, values={"certificate-extra-sans": config_value}) + + juju.wait( + lambda status: does_status_match( + status, + expected_unit_statuses={APP_NAME: [TLSStatuses.SANS_CONFIG_INVALID.value]}, + num_units={APP_NAME: NUM_UNITS}, + ), + timeout=100, + ) + + download_client_certificate_from_unit(juju, APP_NAME) + client_cert_sans = subprocess.getoutput( + f"openssl x509 -noout -ext subjectAltName -in {TLS_CERT_FILE}" + ) + assert config_value not in client_cert_sans, ( + f"config value {config_value} found in certificate sans {client_cert_sans}" + ) + + logger.info("Configure valid extra-sans") + config_value = "server-{unit}.valkey" + juju.config(app=APP_NAME, values={"certificate-extra-sans": config_value}) + + juju.wait( + lambda status: are_apps_active_and_agents_idle(status, APP_NAME, unit_count=NUM_UNITS), + timeout=100, + ) + + # this will download the client cert from application.units[0] + download_client_certificate_from_unit(juju, APP_NAME) + client_cert_sans = subprocess.getoutput( + f"openssl x509 -noout -ext subjectAltName -in {TLS_CERT_FILE}" + ) + unit_name = next(iter(juju.status().get_units(APP_NAME))) + expected_sans = config_value.replace("{unit}", unit_name.split("/")[-1]) + assert expected_sans in client_cert_sans, ( + f"expected sans {expected_sans} not found in certificate sans {client_cert_sans}" + ) + + logger.info("Resetting configuration for extra-sans") + juju.config(app=APP_NAME, reset="certificate-extra-sans") + + juju.wait( + lambda status: are_apps_active_and_agents_idle(status, APP_NAME, unit_count=NUM_UNITS) + ) + + download_client_certificate_from_unit(juju, APP_NAME) + client_cert_sans = subprocess.getoutput( + f"openssl x509 -noout -ext subjectAltName -in {TLS_CERT_FILE}" + ) + assert expected_sans not in client_cert_sans, ( + f"sans value {expected_sans} found in certificate sans {client_cert_sans}" + ) diff --git a/tests/spread/k8s/test_certificate_options.py/task.yaml b/tests/spread/k8s/test_certificate_options.py/task.yaml new file mode 100644 index 0000000..242b938 --- /dev/null +++ b/tests/spread/k8s/test_certificate_options.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_certificate_options.py +environment: + TEST_MODULE: test_certificate_options.py +execute: | + tox run -e integration -- "tests/integration/tls/$TEST_MODULE" --substrate k8s --alluredir="$SPREAD_TASK/allure-results" +artifacts: + - allure-results \ No newline at end of file diff --git a/tests/spread/vm/test_certificate_options.py/task.yaml b/tests/spread/vm/test_certificate_options.py/task.yaml new file mode 100644 index 0000000..a57777d --- /dev/null +++ b/tests/spread/vm/test_certificate_options.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_certificate_options.py +environment: + TEST_MODULE: test_certificate_options.py +execute: | + tox run -e integration -- "tests/integration/tls/$TEST_MODULE" --substrate vm --alluredir="$SPREAD_TASK/allure-results" +artifacts: + - allure-results \ No newline at end of file From f60296367951db0a4e78373737f88eb63a1fa493 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 19 Mar 2026 11:03:18 +0100 Subject: [PATCH 3/5] remove debug logging --- src/managers/tls.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/managers/tls.py b/src/managers/tls.py index 14385fd..6e12a32 100644 --- a/src/managers/tls.py +++ b/src/managers/tls.py @@ -106,7 +106,6 @@ def build_sans_ip(self) -> frozenset[str]: if ingress_ip := self.state.ingress_address: sans_ip.add(ingress_ip) - logger.info(sans_ip) return frozenset(sans_ip) @@ -133,7 +132,6 @@ def build_sans_dns(self) -> frozenset[str]: } sans_dns.add(self.state.unit_server.model.hostname) - logger.info(sans_dns) return frozenset(sans_dns) From 5443d5b87eebb6511ac010c9ce175143368c0081 Mon Sep 17 00:00:00 2001 From: reneradoi Date: Thu, 19 Mar 2026 20:37:00 +0100 Subject: [PATCH 4/5] fix timeout for integration test --- tests/integration/tls/test_certificate_rotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/tls/test_certificate_rotation.py b/tests/integration/tls/test_certificate_rotation.py index feeee2f..14198ef 100644 --- a/tests/integration/tls/test_certificate_rotation.py +++ b/tests/integration/tls/test_certificate_rotation.py @@ -218,7 +218,7 @@ async def test_ca_rotation_by_expiration(juju: jubilant.Juju) -> None: juju.remove_relation(f"{APP_NAME}:client-certificates", f"{TLS_NAME}:certificates") juju.wait( lambda status: are_agents_idle(status, APP_NAME, idle_period=30, unit_count=NUM_UNITS), - timeout=100, + timeout=600, ) logger.info("Adjust CA and certificate validity on TLS provider") From 08562bd5babd4168211d397590fb78fe33c6b6ae Mon Sep 17 00:00:00 2001 From: reneradoi Date: Fri, 20 Mar 2026 10:12:02 +0100 Subject: [PATCH 5/5] merge conflicts --- poetry.lock | 17 ++++++++++++++++- src/events/tls.py | 6 +++--- tests/unit/test_tls.py | 20 +++++++++++--------- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1a143bf..b301175 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1169,6 +1169,21 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "validators" +version = "0.35.0" +description = "Python Data Validation for Humans™" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd"}, + {file = "validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a"}, +] + +[package.extras] +crypto-eth-addresses = ["eth-hash[pycryptodome] (>=0.7.0)"] + [[package]] name = "valkey-glide" version = "0.0.0" @@ -1231,4 +1246,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "9085ffa9d77cbcb0ab1d8858dfdae18db329647e3036be7792c3abb94403dc95" +content-hash = "007a58cbdc969e285b4607c8dd4650fc877ae8c4b154565909eb5debdbb209c9" diff --git a/src/events/tls.py b/src/events/tls.py index 69be62d..280aa38 100644 --- a/src/events/tls.py +++ b/src/events/tls.py @@ -309,12 +309,12 @@ def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: logger.warning("Invalid configuration for 'certificate-extra-sans'") return - if ( - (secret_id := self.charm.config.get(TLS_CLIENT_PRIVATE_KEY_CONFIG)) - and (private_key := self.charm.tls_manager.read_and_validate_private_key(secret_id)) + if (secret_id := self.charm.config.get(TLS_CLIENT_PRIVATE_KEY_CONFIG)) and ( + private_key := self.charm.tls_manager.read_and_validate_private_key(secret_id) ): if self.charm.unit.is_leader(): self.charm.state.cluster.update({"tls_client_private_key": private_key.raw}) + # refresh event will be ignored by the tls lib if the csr is unchanged self.refresh_tls_certificates_event.emit() if ( diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index 8e627de..a41407a 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -995,11 +995,12 @@ def test_invalid_private_key(cloud_spec): model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), ) - state_out = ctx.run(ctx.on.config_changed(), state_in) - # ensure the secret was not populated with the invalid private key - with pytest.raises(KeyError): - state_out.get_secret(label=f"{PEER_RELATION}.{APP_NAME}.app") - assert status_is(state_out, TLSStatuses.PRIVATE_KEY_INVALID.value) + with patch("workload_k8s.ValkeyK8sWorkload.exec"): + state_out = ctx.run(ctx.on.config_changed(), state_in) + # ensure the secret was not populated with the invalid private key + with pytest.raises(KeyError): + state_out.get_secret(label=f"{PEER_RELATION}.{APP_NAME}.app") + assert status_is(state_out, TLSStatuses.PRIVATE_KEY_INVALID.value) def test_private_key_refreshes_certificate(cloud_spec): @@ -1031,10 +1032,11 @@ def test_private_key_refreshes_certificate(cloud_spec): model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), ) - state_out = ctx.run(ctx.on.config_changed(), state_in) - secret_out = state_out.get_secret(label=f"{PEER_RELATION}.{APP_NAME}.app") - assert secret_out.latest_content.get("tls-client-private-key") == private_key.raw - assert ctx.emitted_events[1].handle.kind == "refresh_tls_certificates_event" + with patch("workload_k8s.ValkeyK8sWorkload.exec"): + state_out = ctx.run(ctx.on.config_changed(), state_in) + secret_out = state_out.get_secret(label=f"{PEER_RELATION}.{APP_NAME}.app") + assert secret_out.latest_content.get("tls-client-private-key") == private_key.raw + assert ctx.emitted_events[1].handle.kind == "refresh_tls_certificates_event" def test_private_key_secret_changed(cloud_spec):