diff --git a/config.yaml b/config.yaml index 9307f3b..1bbde57 100644 --- a/config.yaml +++ b/config.yaml @@ -16,3 +16,8 @@ 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 + + tls-client-private-key: + type: secret + description: | + A Juju secret URI of a secret containing the private key for client TLS certificates. diff --git a/poetry.lock b/poetry.lock index a06763d..1a143bf 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" @@ -97,7 +97,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "integration"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, @@ -195,7 +195,7 @@ version = "1.8.0" description = "The charmlibs.interfaces.tls_certificates package." optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "charmlibs_interfaces_tls_certificates-1.8.0-py3-none-any.whl", hash = "sha256:c2d1d0f1fd9dfefcbdb078c810190e18710e04de4faf58139a9290689ad944fc"}, {file = "charmlibs_interfaces_tls_certificates-1.8.0.tar.gz", hash = "sha256:ed29003ecd0b83e6b036936ba4c23bb9eb623a89c66096438b2c017cef7d8e3a"}, @@ -392,7 +392,7 @@ version = "46.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] +groups = ["main", "integration"] files = [ {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"}, {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"}, @@ -704,7 +704,7 @@ version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "integration"] markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, @@ -1231,4 +1231,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "3575fae932d789fe98e4d4bb5272cd80b6122121980ed4ef31538f9f8534b986" +content-hash = "9085ffa9d77cbcb0ab1d8858dfdae18db329647e3036be7792c3abb94403dc95" diff --git a/pyproject.toml b/pyproject.toml index 9977a14..4a04e78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ pytest = "*" pytest-asyncio = "*" allure-pytest = "*" allure-pytest-default-results = "^0.1.2" +charmlibs-interfaces-tls-certificates = ">=1.6.0" data-platform-helpers = ">=0.1.7" jubilant = "^1.6.0" python-dateutil = "*" diff --git a/src/core/models.py b/src/core/models.py index 5c04f2c..a1dc042 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -13,6 +13,7 @@ PrivateKey, ) from charms.data_platform_libs.v1.data_interfaces import ( + ExtraSecretStr, OpsOtherPeerUnitRepositoryInterface, OpsPeerRepositoryInterface, OpsPeerUnitRepositoryInterface, @@ -55,6 +56,7 @@ class PeerAppModel(PeerModel): start_member: str = Field(default="") internal_ca_certificate: InternalCertificatesSecret = Field(default="") internal_ca_private_key: InternalCertificatesSecret = Field(default="") + tls_client_private_key: ExtraSecretStr = Field(default=None) class PeerUnitModel(PeerModel): @@ -235,3 +237,11 @@ def internal_ca_private_key(self) -> PrivateKey | None: return None return PrivateKey.from_string(self.model.internal_ca_private_key) + + @property + def tls_client_private_key(self) -> PrivateKey | None: + """Retrieve the private key for client TLS.""" + if self.model and (private_key := self.model.tls_client_private_key): + return PrivateKey(raw=private_key) + + return None diff --git a/src/events/tls.py b/src/events/tls.py index 845bd95..dc830bf 100644 --- a/src/events/tls.py +++ b/src/events/tls.py @@ -24,6 +24,7 @@ CLIENT_PORT, CLIENT_TLS_RELATION_NAME, PEER_RELATION, + TLS_CLIENT_PRIVATE_KEY_CONFIG, TLSCARotationState, TLSState, ) @@ -57,7 +58,7 @@ def __init__(self, charm: "ValkeyCharm"): sans_dns=self.charm.tls_manager.build_sans_dns(), ), ], - private_key=None, + private_key=self.charm.tls_manager.get_client_tls_private_key(), refresh_events=[self.refresh_tls_certificates_event], ) @@ -78,6 +79,8 @@ 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.secret_changed, self._on_secret_changed) + 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 +285,39 @@ 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_secret_changed(self, event: ops.SecretChangedEvent) -> None: + """Handle TLS related secret changes.""" + if not (secret_id := self.charm.config.get(TLS_CLIENT_PRIVATE_KEY_CONFIG)): + return + + if secret_id != event.secret.id: + return + + if not (private_key := self.charm.tls_manager.read_and_validate_private_key(secret_id)): + logger.error("Invalid private key provided, cannot update TLS certificates.") + return + + if self.charm.unit.is_leader(): + self.charm.state.cluster.update({"tls_client_private_key": private_key.raw}) + + if self.charm.state.client_tls_relation: + self.refresh_tls_certificates_event.emit() + + def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: + """Handle TLS related config changes.""" + if not (secret_id := self.charm.config.get(TLS_CLIENT_PRIVATE_KEY_CONFIG)): + return + + if not (private_key := self.charm.tls_manager.read_and_validate_private_key(secret_id)): + logger.error("Invalid private key provided, cannot update TLS certificates.") + return + + if self.charm.unit.is_leader(): + self.charm.state.cluster.update({"tls_client_private_key": private_key.raw}) + + if self.charm.state.client_tls_relation: + 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/literals.py b/src/literals.py index 408f473..95282ad 100644 --- a/src/literals.py +++ b/src/literals.py @@ -41,6 +41,7 @@ INTERNAL_USERS_PASSWORD_CONFIG = "system-users" INTERNAL_USERS_SECRET_LABEL_SUFFIX = "internal_users_secret" INTERNET_CERTS_SECRET_LABEL_SUFFIX = "internal_certificates_secret" +TLS_CLIENT_PRIVATE_KEY_CONFIG = "tls-client-private-key" DATA_STORAGE = "data" diff --git a/src/managers/tls.py b/src/managers/tls.py index 13ce3af..a838bce 100644 --- a/src/managers/tls.py +++ b/src/managers/tls.py @@ -4,7 +4,10 @@ """Manager for handling TLS-related operations.""" +import base64 +import binascii import logging +import re from datetime import timedelta from charmlibs.interfaces.tls_certificates import ( @@ -17,11 +20,12 @@ 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 ops import ModelError, SecretNotFoundError from common.exceptions import ValkeyWorkloadCommandError from core.base_workload import WorkloadBase from core.cluster_state import ClusterState -from literals import TLSCARotationState, TLSState +from literals import TLS_CLIENT_PRIVATE_KEY_CONFIG, TLSCARotationState, TLSState from statuses import CharmStatuses, TLSStatuses logger = logging.getLogger(__name__) @@ -117,6 +121,60 @@ def build_sans_dns(self) -> frozenset[str]: return frozenset(sans_dns) + def read_and_validate_private_key(self, private_key_secret_id: str) -> PrivateKey | None: + """Read and validate a private key provided via Juju secret. + + Args: + private_key_secret_id (str): The Juju secret ID for the secret + that stores the private key. + + Returns: + PrivateKey: The private key. + """ + try: + secret_content = self.state.get_secret_from_id(private_key_secret_id).get( + "private-key" + ) + except (ModelError, SecretNotFoundError) as e: + logger.error(e) + return None + + if secret_content is None: + logger.error(f"Secret {private_key_secret_id} does not contain a private key.") + return None + + try: + private_key = ( + secret_content + if re.match(r"(-+(BEGIN|END) [A-Z ]+-+)", secret_content) + else base64.b64decode(secret_content).decode("utf-8").strip() + ) + except (UnicodeDecodeError, binascii.Error) as e: + logger.error(e) + return None + try: + private_key = PrivateKey(raw=private_key) + except ValueError as e: + logger.error(e) + return None + if not private_key.is_valid(): + logger.error("Invalid private key format.") + return None + + return private_key + + def get_client_tls_private_key(self) -> PrivateKey | None: + """Get the private key provided by users, if available.""" + if secret_id := self.state.config.get(TLS_CLIENT_PRIVATE_KEY_CONFIG): + if private_key := self.read_and_validate_private_key(secret_id): + return private_key + + # in case the configured secret is invalid + return self.state.cluster.tls_client_private_key + + # in case no user supplied private key configured + return None + def _generate_private_key(self) -> PrivateKey: """Generate a private key for use in peer TLS.""" return PrivateKey.generate() @@ -246,7 +304,7 @@ def start_ca_rotation_if_required( ) return True - def get_statuses(self, scope: Scope, recompute: bool = False) -> list[StatusObject]: + def get_statuses(self, scope: Scope, recompute: bool = False) -> list[StatusObject]: # noqa: C901 """Compute the TLS statuses.""" status_list: list[StatusObject] = [] @@ -264,6 +322,14 @@ def get_statuses(self, scope: Scope, recompute: bool = False) -> list[StatusObje ): status_list.append(TLSStatuses.DISABLING_CLIENT_TLS_FAILED.value) + if self.state.cluster.tls_client_private_key and not self.state.client_tls_relation: + status_list.append(TLSStatuses.PRIVATE_KEY_BUT_NO_TLS.value) + + if ( + private_key_id := self.state.config.get(TLS_CLIENT_PRIVATE_KEY_CONFIG) + ) and self.read_and_validate_private_key(str(private_key_id)) is None: + status_list.append(TLSStatuses.PRIVATE_KEY_INVALID.value) + if self.state.unit_server.tls_client_state == TLSState.TO_NO_TLS: status_list.append(TLSStatuses.DISABLING_CLIENT_TLS.value) diff --git a/src/statuses.py b/src/statuses.py index 66c7e87..2913f5a 100644 --- a/src/statuses.py +++ b/src/statuses.py @@ -116,3 +116,10 @@ class TLSStatuses(Enum): CA_ROTATION_UPDATED = StatusObject( status="maintenance", message="TLS CA rotation: certificates updated" ) + PRIVATE_KEY_BUT_NO_TLS = StatusObject( + status="blocked", message="Private Key provided, but client TLS not enabled" + ) + PRIVATE_KEY_INVALID = StatusObject( + status="blocked", + message="The private key provided is not valid. Please provide a valid private key", + ) diff --git a/tests/integration/tls/test_certificate_rotation.py b/tests/integration/tls/test_certificate_rotation.py index feeee2f..cb7d7fa 100644 --- a/tests/integration/tls/test_certificate_rotation.py +++ b/tests/integration/tls/test_certificate_rotation.py @@ -226,7 +226,7 @@ async def test_ca_rotation_by_expiration(juju: jubilant.Juju) -> None: juju.config(app=TLS_NAME, values=tls_config) juju.wait( lambda status: are_agents_idle(status, TLS_NAME, idle_period=30), - timeout=100, + timeout=600, ) logger.info("Enabling client TLS again") diff --git a/tests/integration/tls/test_private_key.py b/tests/integration/tls/test_private_key.py new file mode 100644 index 0000000..0ec5997 --- /dev/null +++ b/tests/integration/tls/test_private_key.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. +import logging + +import jubilant +from charmlibs.interfaces.tls_certificates import PrivateKey + +from literals import TLS_CLIENT_PRIVATE_KEY_CONFIG, CharmUsers, Substrate +from statuses import TLSStatuses +from tests.integration.helpers import ( + APP_NAME, + IMAGE_RESOURCE, + TLS_CERT_FILE, + TLS_CHANNEL, + TLS_KEY_FILE, + TLS_NAME, + are_agents_idle, + does_status_match, + download_client_certificate_from_unit, + get_cluster_hostnames, + get_key, + get_password, + set_key, +) + +logger = logging.getLogger(__name__) + +NUM_UNITS = 3 +TEST_KEY = "test_key" +TEST_VALUE = "test_value" + + +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.wait( + lambda status: are_agents_idle(status, APP_NAME, idle_period=30, unit_count=NUM_UNITS), + timeout=600, + ) + + +def test_invalid_private_key(juju: jubilant.Juju) -> None: + """Ensure an invalid private key is not harmful.""" + logger.info("Adding invalid private key as user secret") + private_key = "invalid-private-key" + secret_id = juju.add_secret( + name=TLS_CLIENT_PRIVATE_KEY_CONFIG, + content={"private-key": private_key}, + ) + juju.grant_secret(TLS_CLIENT_PRIVATE_KEY_CONFIG, APP_NAME) + + logger.info("Setting configuration option to Valkey") + juju.config(app=APP_NAME, values={TLS_CLIENT_PRIVATE_KEY_CONFIG: secret_id}) + juju.wait( + lambda status: does_status_match( + status, + expected_unit_statuses={APP_NAME: [TLSStatuses.PRIVATE_KEY_INVALID.value]}, + num_units={APP_NAME: NUM_UNITS}, + ), + timeout=100, + ) + + +async def test_valid_private_key(juju: jubilant.Juju) -> None: + logger.info("Updating user secret with valid private key now") + private_key = PrivateKey.generate().raw + + juju.update_secret( + identifier=TLS_CLIENT_PRIVATE_KEY_CONFIG, + content={"private-key": private_key}, + ) + juju.wait( + lambda status: does_status_match( + status, + expected_unit_statuses={APP_NAME: [TLSStatuses.PRIVATE_KEY_BUT_NO_TLS.value]}, + num_units={APP_NAME: NUM_UNITS}, + ), + timeout=100, + ) + + logger.info("Enabling client TLS") + 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, + ) + + logger.info("Downloading TLS certificate from deployed app.") + download_client_certificate_from_unit(juju, APP_NAME) + + logger.info("Check access with TLS enabled") + hostnames = get_cluster_hostnames(juju, APP_NAME) + result = await set_key( + hostnames=hostnames, + username=CharmUsers.VALKEY_ADMIN.value, + password=get_password(juju, user=CharmUsers.VALKEY_ADMIN), + tls_enabled=True, + key=TEST_KEY, + value=TEST_VALUE, + ) + assert result == "OK", "Failed to write data with TLS enabled" + + assert await get_key( + hostnames=hostnames, + username=CharmUsers.VALKEY_ADMIN.value, + password=get_password(juju, user=CharmUsers.VALKEY_ADMIN), + tls_enabled=True, + key=TEST_KEY, + ) == bytes(TEST_VALUE, "utf-8"), "Failed to read data with TLS enabled" + + logger.info("Store current certificate before expiration") + with open(TLS_KEY_FILE, "r") as key_file: + private_key_on_unit = key_file.read() + assert private_key_on_unit, "Failed to get current client certificate private key" + assert private_key_on_unit == private_key, "Expected user-provided private key to be used" + + +async def test_private_key_updated(juju: jubilant.Juju) -> None: + logger.info("Getting current private key and certificate") + with open(TLS_KEY_FILE, "r") as key_file: + current_private_key = key_file.read() + assert current_private_key, "Failed to get current private key" + with open(TLS_CERT_FILE, "r") as cert_file: + current_certificate = cert_file.read() + assert current_certificate, "Failed to get current certificate" + + logger.info("Updating the private key") + new_private_key = PrivateKey.generate().raw + juju.update_secret( + identifier=TLS_CLIENT_PRIVATE_KEY_CONFIG, + content={"private-key": new_private_key}, + ) + juju.wait( + lambda status: are_agents_idle(status, APP_NAME, idle_period=30, unit_count=NUM_UNITS), + timeout=600, + ) + + logger.info("Downloading TLS certificate from deployed app.") + download_client_certificate_from_unit(juju, APP_NAME) + + logger.info("Check access with TLS enabled") + hostnames = get_cluster_hostnames(juju, APP_NAME) + result = await set_key( + hostnames=hostnames, + username=CharmUsers.VALKEY_ADMIN.value, + password=get_password(juju, user=CharmUsers.VALKEY_ADMIN), + tls_enabled=True, + key=TEST_KEY, + value=TEST_VALUE, + ) + assert result == "OK", "Failed to write data with TLS enabled" + + assert await get_key( + hostnames=hostnames, + username=CharmUsers.VALKEY_ADMIN.value, + password=get_password(juju, user=CharmUsers.VALKEY_ADMIN), + tls_enabled=True, + key=TEST_KEY, + ) == bytes(TEST_VALUE, "utf-8"), "Failed to read data with TLS enabled" + + logger.info("Getting and comparing updated private key and certificate") + with open(TLS_KEY_FILE, "r") as key_file: + updated_private_key = key_file.read() + assert updated_private_key, "Failed to get updated private key" + assert updated_private_key != current_private_key, "Private key was not updated" + assert updated_private_key == new_private_key, "Private key does not match after update" + + with open(TLS_CERT_FILE, "r") as cert_file: + updated_certificate = cert_file.read() + assert updated_certificate, "Failed to get updated certificate" + assert updated_certificate != current_certificate, "Certificate was not updated" diff --git a/tests/spread/k8s/test_private_key.py/task.yaml b/tests/spread/k8s/test_private_key.py/task.yaml new file mode 100644 index 0000000..942bace --- /dev/null +++ b/tests/spread/k8s/test_private_key.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_private_key.py +environment: + TEST_MODULE: test_private_key.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_private_key.py/task.yaml b/tests/spread/vm/test_private_key.py/task.yaml new file mode 100644 index 0000000..bfccf29 --- /dev/null +++ b/tests/spread/vm/test_private_key.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_private_key.py +environment: + TEST_MODULE: test_private_key.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..a69c148 100644 --- a/tests/unit/test_tls.py +++ b/tests/unit/test_tls.py @@ -5,8 +5,13 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest import yaml -from charmlibs.interfaces.tls_certificates import CertificateAvailableEvent, ProviderCertificate +from charmlibs.interfaces.tls_certificates import ( + CertificateAvailableEvent, + PrivateKey, + ProviderCertificate, +) from ops import testing from src.charm import ValkeyCharm @@ -16,6 +21,7 @@ INTERNET_CERTS_SECRET_LABEL_SUFFIX, PEER_RELATION, STATUS_PEERS_RELATION, + TLS_CLIENT_PRIVATE_KEY_CONFIG, TLSCARotationState, ) from src.statuses import TLSStatuses @@ -927,3 +933,140 @@ 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_private_key_without_client_tls(cloud_spec): + ctx = testing.Context(ValkeyCharm, app_trusted=True) + peer_relation = testing.PeerRelation( + id=1, + endpoint=PEER_RELATION, + local_unit_data={"start-state": "started"}, + ) + status_peer_relation = testing.PeerRelation(id=2, endpoint=STATUS_PEERS_RELATION) + container = testing.Container(name=CONTAINER, can_connect=True) + + private_key = PrivateKey.generate() + secret = testing.Secret( + {"private-key": private_key.raw}, + label=TLS_CLIENT_PRIVATE_KEY_CONFIG, + ) + + state_in = testing.State( + leader=True, + relations={peer_relation, status_peer_relation}, + secrets={secret}, + config={TLS_CLIENT_PRIVATE_KEY_CONFIG: secret.id}, + 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) + 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 status_is(state_out, TLSStatuses.PRIVATE_KEY_BUT_NO_TLS.value) + + +def test_invalid_private_key(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) + + private_key = "invalid-private-key" + secret = testing.Secret( + {"private-key": private_key}, + label=TLS_CLIENT_PRIVATE_KEY_CONFIG, + ) + + state_in = testing.State( + leader=True, + relations={peer_relation, status_peer_relation, client_tls_relation}, + secrets={secret}, + config={TLS_CLIENT_PRIVATE_KEY_CONFIG: secret.id}, + 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) + # 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): + 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) + + private_key = PrivateKey.generate() + user_secret = testing.Secret( + {"private-key": private_key.raw}, + label=TLS_CLIENT_PRIVATE_KEY_CONFIG, + ) + + state_in = testing.State( + leader=True, + relations={peer_relation, status_peer_relation, client_tls_relation}, + secrets={user_secret}, + config={TLS_CLIENT_PRIVATE_KEY_CONFIG: user_secret.id}, + 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) + 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): + 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) + + private_key = PrivateKey.generate() + user_secret = testing.Secret( + {"private-key": private_key.raw}, + label=TLS_CLIENT_PRIVATE_KEY_CONFIG, + ) + + state_in = testing.State( + leader=True, + relations={peer_relation, status_peer_relation, client_tls_relation}, + secrets={user_secret}, + config={TLS_CLIENT_PRIVATE_KEY_CONFIG: user_secret.id}, + containers={container}, + model=testing.Model(name="my-vm-model", type="lxd", cloud_spec=cloud_spec), + ) + + state_out = ctx.run(ctx.on.secret_changed(secret=user_secret), 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"