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..de29ba7 100644 --- a/src/events/tls.py +++ b/src/events/tls.py @@ -10,6 +10,7 @@ import ops from charmlibs.interfaces.tls_certificates import ( CertificateAvailableEvent, + CertificateDeniedEvent, CertificateRequestAttributes, TLSCertificatesRequiresV4, ) @@ -71,6 +72,9 @@ def __init__(self, charm: "ValkeyCharm"): self.framework.observe( self.client_certificate.on.certificate_available, self._on_certificate_available ) + self.framework.observe( + self.client_certificate.on.certificate_denied, self._on_certificate_denied + ) self.framework.observe( self.charm.on[PEER_RELATION].relation_created, self._on_peer_relation_created ) @@ -78,6 +82,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.""" @@ -209,6 +214,14 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: event.defer() return + def _on_certificate_denied(self, event: CertificateDeniedEvent) -> None: + """Handle the `certificate-denied` event from TLS provider.""" + if event.certificate_signing_request in [ + csr.certificate_signing_request + for csr in self.client_certificate.get_csrs_from_requirer_relation_data() + ]: + logger.error("Certificate request was denied: %s", event.error.message) + def _on_tls_relation_broken(self, event: ops.RelationBrokenEvent) -> None: """Handle the `relation-broken` event.""" if self.charm.app.planned_units() == 0 or self.charm.state.unit_server.is_being_removed: @@ -282,6 +295,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..81fb8fe 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,6 +96,12 @@ 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: @@ -113,6 +121,16 @@ 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) return frozenset(sans_dns) @@ -246,7 +264,97 @@ def start_ca_rotation_if_required( ) return True - def get_statuses(self, scope: Scope, recompute: bool = False) -> list[StatusObject]: + 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]: # noqa: C901 """Compute the TLS statuses.""" status_list: list[StatusObject] = [] @@ -254,6 +362,11 @@ def get_statuses(self, scope: Scope, recompute: bool = False) -> list[StatusObje if not self.state.cluster.model or not self.state.unit_server.model: return status_list or [CharmStatuses.ACTIVE_IDLE.value] + if (relation := self.state.client_tls_relation) and relation.data[relation.app].get( + "request_errors" + ): + status_list.append(TLSStatuses.CERTIFICATE_DENIED.value) + if self.state.unit_server.tls_client_state == TLSState.TO_TLS: status_list.append(TLSStatuses.ENABLING_CLIENT_TLS.value) @@ -278,4 +391,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..d08419b 100644 --- a/src/statuses.py +++ b/src/statuses.py @@ -116,3 +116,11 @@ 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`", + ) + CERTIFICATE_DENIED = StatusObject( + status="blocked", message="Certificate request was denied, check logs for details" + ) diff --git a/tests/integration/tls/test_certificate_options.py b/tests/integration/tls/test_certificate_options.py new file mode 100644 index 0000000..246432b --- /dev/null +++ b/tests/integration/tls/test_certificate_options.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +import logging +import os +import re +import subprocess +from pathlib import Path + +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 +VAULT_NAME = "vault" + + +def test_build_and_deploy(charm: str, juju: jubilant.Juju, substrate: Substrate) -> None: + """Deploy the charm under test and a TLS provider.""" + _install_dependencies() + + 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.deploy( + "vault-k8s" if substrate == Substrate.K8S else "vault", + app=VAULT_NAME, + channel="1.18/edge", + config={ + "pki_ca_common_name": "mydomain.com", + "pki_allow_any_name": False, + "pki_allow_ip_sans": False, + }, + ) + 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, + ) + juju.wait(lambda status: jubilant.all_blocked(status, VAULT_NAME)) + + +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}" + ) + + logger.info("Remove relation with %s", TLS_NAME) + 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=600, + ) + + +def test_initialize_vault(juju: jubilant.Juju, substrate: Substrate) -> None: + """Initialize Vault and wait for it to be ready.""" + vault_units = juju.status().get_units(VAULT_NAME) + vault_unit = next(iter(vault_units.values())) + vault_ip = ( + juju.status().apps[VAULT_NAME].address + if substrate == Substrate.K8S + else vault_unit.public_address + ) + secrets = juju.secrets() + logger.info("Initializing Vault") + + vault_ca = None + for secret in secrets: + if secret.label == "self-signed-vault-ca-certificate": + vault_ca = juju.show_secret(identifier=secret.uri, reveal=True).content.get( + "certificate" + ) + + assert vault_ca, "Vault CA certificate not found in secrets" + + Path("./vault_ca.pem").write_text(vault_ca) + + vault_env = os.environ.copy() + vault_env["VAULT_CACERT"] = "./vault_ca.pem" + vault_env["VAULT_ADDR"] = f"https://{vault_ip}:8200" + + # operator init + logger.info("Running vault operator init") + init_cmd = [ + "vault", + "operator", + "init", + "-key-shares=1", + "-key-threshold=1", + ] + init_result = subprocess.run( + init_cmd, check=True, text=True, capture_output=True, env=vault_env + ) + logger.info(f"Vault operator init output: {init_result.stdout}") + init_results_list = [line.strip() for line in init_result.stdout.splitlines() if line.strip()] + unseal_key = init_results_list[0].split(":")[1].strip() + root_token = init_results_list[1].split(":")[1].strip() + vault_env["VAULT_TOKEN"] = root_token + + # operator unseal + logger.info("Running vault operator unseal") + unseal_cmd = [ + "vault", + "operator", + "unseal", + unseal_key, + ] + unseal_result = subprocess.run( + unseal_cmd, check=True, text=True, capture_output=True, env=vault_env + ) + logger.info(f"Vault operator unseal output: {unseal_result.stdout}") + + # authorise vault charm + # create vault token + logger.info("Creating Vault token for the vault charm") + create_token_cmd = [ + "vault", + "token", + "create", + "-ttl=60m", + ] + create_token_result = subprocess.run( + create_token_cmd, check=True, text=True, capture_output=True, env=vault_env + ) + logger.info(f"Vault token create output: {create_token_result.stdout}") + token_regex = r"token\s+([\w\.]+)" + + # extract token using regex + match = re.search(token_regex, create_token_result.stdout) + assert match, "Failed to extract token from Vault token create output" + charm_vault_token = match.group(1) + secret_id = juju.add_secret( + "vault-token", + { + "token": charm_vault_token, + }, + ) + + assert secret_id, "Failed to create vault-token secret" + juju.grant_secret("vault-token", VAULT_NAME) + + vault_unit_name = next(iter(vault_units)) + action = juju.run( + unit=vault_unit_name, + action="authorize-charm", + params={ + "secret-id": str(secret_id), + }, + ) + + assert action.status == "completed", "Action should succeed" + juju.wait(lambda status: are_apps_active_and_agents_idle(status, VAULT_NAME)) + + +def test_certificate_denied(juju: jubilant.Juju) -> None: + """Process denied certificate request.""" + logger.info("Integrate %s with %s for Intermediate CA", VAULT_NAME, TLS_NAME) + juju.integrate(f"{VAULT_NAME}:tls-certificates-pki", TLS_NAME) + juju.wait(lambda status: are_agents_idle(status, VAULT_NAME, idle_period=30), timeout=600) + + logger.info("Integrate Valkey with Vault for client TLS") + logger.info("Certificate requests should be denied because Vault does not allow IP SANs") + juju.integrate(f"{APP_NAME}:client-certificates", VAULT_NAME) + juju.wait( + lambda status: does_status_match( + status, + expected_unit_statuses={APP_NAME: [TLSStatuses.CERTIFICATE_DENIED.value]}, + num_units={APP_NAME: NUM_UNITS}, + ), + timeout=600, + ) + + +def _install_dependencies() -> None: + """Install dependencies for the test.""" + # Install TLS Certificates interface library + subprocess.run( + ["sudo", "snap", "install", "vault"], check=True, text=True, capture_output=True + ) 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 diff --git a/tests/unit/test_tls.py b/tests/unit/test_tls.py index 2f49a08..55478c1 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -6,7 +6,13 @@ from unittest.mock import MagicMock, patch import yaml -from charmlibs.interfaces.tls_certificates import CertificateAvailableEvent, ProviderCertificate +from charmlibs.interfaces.tls_certificates import ( + CertificateAvailableEvent, + CertificateDeniedEvent, + CertificateError, + ProviderCertificate, + RequirerCertificateRequest, +) from ops import testing from src.charm import ValkeyCharm @@ -247,40 +253,50 @@ def test_client_tls_relation_broken_writing_internal_cert_fails(cloud_spec): assert state_out.get_relation(1).local_unit_data.get("tls-client-state") == "no-tls" -def test_client_tls_relation_broken_run_deferred_event(cloud_spec): +def test_client_certificate_denied(cloud_spec): + csr = MagicMock("my_csr") + 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": "to-no-tls", - "client-cert-ready": "true", - }, + local_unit_data={"start-state": "started", "tls-client-state": "to-tls"}, ) status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) - client_tls_relation = testing.Relation(id=3, endpoint=CLIENT_TLS_RELATION_NAME) + client_tls_relation = testing.Relation( + id=3, + endpoint=CLIENT_TLS_RELATION_NAME, + ) + requirer_certificate_request = RequirerCertificateRequest( + relation_id=3, certificate_signing_request=csr, is_ca=False + ) + certificate_error = CertificateError( + code=101, name="IP not allowed", message="IP address not allowed" + ) container = testing.Container(name=CONTAINER, can_connect=True) - state_in = testing.State( leader=True, relations={peer_relation, status_peer_relation, client_tls_relation}, containers={container}, model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), ) + with ctx(ctx.on.update_status(), state_in) as manager: + charm: ValkeyCharm = manager.charm + event = MagicMock(spec=CertificateDeniedEvent) - with ( - patch("managers.cluster.ClusterManager.reload_tls_settings"), - patch("managers.sentinel.SentinelManager.restart_service"), - patch("charmlibs.pathops.ContainerPath.mkdir"), - patch( - "common.client.SentinelClient.get_primary_addr_by_name", - return_value=("10.0.1.1", 6379), - ), - ): - state_out = ctx.run(ctx.on.relation_broken(relation=client_tls_relation), state_in) - assert state_out.get_relation(1).local_unit_data.get("client-cert-ready") == "false" - assert state_out.get_relation(1).local_unit_data.get("tls-client-state") == "no-tls" + with ( + patch( + "charmlibs.interfaces.tls_certificates.TLSCertificatesRequiresV4.get_csrs_from_requirer_relation_data", + return_value=[requirer_certificate_request], + ), + patch("managers.tls.TLSManager.will_certificate_expire"), + ): + event.certificate_signing_request = csr + event.error = certificate_error + charm.tls_events._on_certificate_denied(event) + state_out = manager.run() + + status_is(state_out, TLSStatuses.CERTIFICATE_DENIED.value) def test_client_certificate_available(cloud_spec): @@ -927,3 +943,217 @@ 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 + + +def test_client_tls_relation_broken_run_deferred_event(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": "to-no-tls", + "client-cert-ready": "true", + }, + ) + 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( + leader=True, + relations={peer_relation, status_peer_relation, client_tls_relation}, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + with ( + patch("managers.cluster.ClusterManager.reload_tls_settings"), + patch("managers.sentinel.SentinelManager.restart_service"), + patch("charmlibs.pathops.ContainerPath.mkdir"), + patch( + "common.client.SentinelClient.get_primary_addr_by_name", + return_value=("10.0.1.1", 6379), + ), + ): + state_out = ctx.run(ctx.on.relation_broken(relation=client_tls_relation), state_in) + assert state_out.get_relation(1).local_unit_data.get("client-cert-ready") == "false" + assert state_out.get_relation(1).local_unit_data.get("tls-client-state") == "no-tls"