From 4a0399e62f2c438e823047c2b490017830f95891 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 16 Aug 2022 11:49:41 -0300 Subject: [PATCH 01/34] Change charm database user --- actions.yaml | 5 +-- lib/charms/postgresql_k8s/v0/postgresql.py | 2 +- src/charm.py | 31 ++++++++++--------- src/cluster.py | 5 ++- src/constants.py | 1 + templates/patroni.yml.j2 | 2 +- tests/integration/helpers.py | 16 +++++----- tests/integration/test_charm.py | 8 ++--- tests/unit/test_charm.py | 36 +++++++++++----------- 9 files changed, 56 insertions(+), 50 deletions(-) diff --git a/actions.yaml b/actions.yaml index 059e17b2ba..f465bd1a91 100644 --- a/actions.yaml +++ b/actions.yaml @@ -3,5 +3,6 @@ get-primary: description: Get the unit which is the primary/leader in the replication. -get-initial-password: - description: Get the initial postgres user password for the database. +get-operator-password: + description: Get the operator user password used by charm. + It is internal charm user, SHOULD NOT be used by applications. diff --git a/lib/charms/postgresql_k8s/v0/postgresql.py b/lib/charms/postgresql_k8s/v0/postgresql.py index d96edfb960..f901204051 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql.py +++ b/lib/charms/postgresql_k8s/v0/postgresql.py @@ -155,7 +155,7 @@ def delete_user(self, user: str) -> None: with self._connect_to_database( database ) as connection, connection.cursor() as cursor: - cursor.execute(sql.SQL("REASSIGN OWNED BY {} TO postgres;").format(sql.Identifier(user))) + cursor.execute(sql.SQL("REASSIGN OWNED BY {} TO operator;").format(sql.Identifier(user))) cursor.execute(sql.SQL("DROP OWNED BY {};").format(sql.Identifier(user))) # Delete the user. diff --git a/src/charm.py b/src/charm.py index 076ac5f177..d171dfa050 100755 --- a/src/charm.py +++ b/src/charm.py @@ -36,7 +36,7 @@ RemoveRaftMemberFailedError, SwitchoverFailedError, ) -from constants import PEER +from constants import PEER, USER from relations.postgresql_provider import PostgreSQLProvider from utils import new_password @@ -60,7 +60,9 @@ def __init__(self, *args): self.framework.observe(self.on[PEER].relation_departed, self._on_peer_relation_departed) self.framework.observe(self.on.pgdata_storage_detaching, self._on_pgdata_storage_detaching) self.framework.observe(self.on.start, self._on_start) - self.framework.observe(self.on.get_initial_password_action, self._on_get_initial_password) + self.framework.observe( + self.on.get_operator_password_action, self._on_get_operator_password + ) self.framework.observe(self.on.update_status, self._on_update_status) self._cluster_name = self.app.name self._member_name = self.unit.name.replace("/", "-") @@ -73,8 +75,8 @@ def postgresql(self) -> PostgreSQL: """Returns an instance of the object used to interact with the database.""" return PostgreSQL( host=self.primary_endpoint, - user="postgres", - password=self._get_postgres_password(), + user=USER, + password=self._get_operator_password(), database="postgres", ) @@ -324,7 +326,7 @@ def _patroni(self) -> Patroni: self._member_name, self.app.planned_units(), self._peer_members_ips, - self._get_postgres_password(), + self._get_operator_password(), self._replication_password, ) @@ -459,7 +461,7 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: """Handle the leader-elected event.""" data = self._peers.data[self.app] # The leader sets the needed password on peer relation databag if they weren't set before. - data.setdefault("postgres-password", new_password()) + data.setdefault("operator-password", new_password()) data.setdefault("replication-password", new_password()) # Update the list of the current PostgreSQL hosts when a new leader is elected. @@ -500,11 +502,10 @@ def _on_start(self, event) -> None: if self._has_blocked_status: return - postgres_password = self._get_postgres_password() - replication_password = self._get_postgres_password() + postgres_password = self._get_operator_password() # If the leader was not elected (and the needed passwords were not generated yet), # the cluster cannot be bootstrapped yet. - if not postgres_password or not replication_password: + if not postgres_password or not self._replication_password: logger.info("leader not elected and/or passwords not yet generated") self.unit.status = WaitingStatus("awaiting passwords generation") event.defer() @@ -538,9 +539,9 @@ def _on_start(self, event) -> None: self._peers.data[self.app]["cluster_initialised"] = "True" self.unit.status = ActiveStatus() - def _on_get_initial_password(self, event: ActionEvent) -> None: - """Returns the password for the postgres user as an action response.""" - event.set_results({"postgres-password": self._get_postgres_password()}) + def _on_get_operator_password(self, event: ActionEvent) -> None: + """Returns the password for the operator user as an action response.""" + event.set_results({"operator-password": self._get_operator_password()}) def _on_update_status(self, _) -> None: """Update endpoints of the postgres client relation and update users list.""" @@ -552,15 +553,15 @@ def _has_blocked_status(self) -> bool: """Returns whether the unit is in a blocked state.""" return isinstance(self.unit.status, BlockedStatus) - def _get_postgres_password(self) -> str: - """Get postgres user password. + def _get_operator_password(self) -> str: + """Get operator user password. Returns: The password from the peer relation or None if the password has not yet been set by the leader. """ data = self._peers.data[self.app] - return data.get("postgres-password") + return data.get("operator-password") @property def _replication_password(self) -> str: diff --git a/src/cluster.py b/src/cluster.py index 272de20e9e..03ece3d92f 100644 --- a/src/cluster.py +++ b/src/cluster.py @@ -28,6 +28,8 @@ wait_fixed, ) +from constants import USER + logger = logging.getLogger(__name__) PATRONI_SERVICE = "patroni" @@ -70,7 +72,7 @@ def __init__( member_name: name of the member inside the cluster peers_ips: IP addresses of the peer units planned_units: number of units planned for the cluster - superuser_password: password for the postgres user + superuser_password: password for the operator user replication_password: password for the user used in the replication """ self.unit_ip = unit_ip @@ -259,6 +261,7 @@ def _render_patroni_yml_file(self, replica: bool = False) -> None: scope=self.cluster_name, self_ip=self.unit_ip, replica=replica, + superuser=USER, superuser_password=self.superuser_password, replication_password=self.replication_password, version=self._get_postgresql_version(), diff --git a/src/constants.py b/src/constants.py index 1ce25738e5..a3427b8c2d 100644 --- a/src/constants.py +++ b/src/constants.py @@ -7,3 +7,4 @@ DATABASE_PORT = "5432" PEER = "database-peers" ALL_CLIENT_RELATIONS = [DATABASE] +USER = "operator" diff --git a/templates/patroni.yml.j2 b/templates/patroni.yml.j2 index 375f7932d8..0ced378216 100644 --- a/templates/patroni.yml.j2 +++ b/templates/patroni.yml.j2 @@ -62,5 +62,5 @@ postgresql: username: replication password: {{ replication_password }} superuser: - username: postgres + username: {{ superuser }} password: {{ superuser_password }} diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 7dc731b299..8d84503160 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -57,13 +57,13 @@ def db_connect(host: str, password: str) -> psycopg2.extensions.connection: Args: host: the IP of the postgres host - password: postgres password + password: operator user password Returns: - psycopg2 connection object linked to postgres db, under "postgres" user. + psycopg2 connection object linked to postgres db, under "operator" user. """ return psycopg2.connect( - f"dbname='postgres' user='postgres' host='{host}' password='{password}' connect_timeout=10" + f"dbname='postgres' user='operator' host='{host}' password='{password}' connect_timeout=10" ) @@ -114,20 +114,20 @@ def get_application_units_ips(ops_test: OpsTest, application_name: str) -> List[ return [unit.public_address for unit in ops_test.model.applications[application_name].units] -async def get_postgres_password(ops_test: OpsTest, unit_name: str) -> str: - """Retrieve the postgres user password using the action. +async def get_operator_password(ops_test: OpsTest, unit_name: str) -> str: + """Retrieve the operator user password using the action. Args: ops_test: ops_test instance. unit_name: the name of the unit. Returns: - the postgres user password. + the operator user password. """ unit = ops_test.model.units.get(unit_name) - action = await unit.run_action("get-initial-password") + action = await unit.run_action("get-operator-password") result = await action.wait() - return result.results["postgres-password"] + return result.results["operator-password"] async def get_primary(ops_test: OpsTest, unit_name: str) -> str: diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 9a7d1620db..9d32a78044 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -19,7 +19,7 @@ convert_records_to_dict, db_connect, find_unit, - get_postgres_password, + get_operator_password, get_primary, get_unit_address, scale_application, @@ -85,12 +85,12 @@ async def test_settings_are_correct(ops_test: OpsTest, series: str, unit_id: int # Set a composite application name in order to test in more than one series at the same time. application_name = build_application_name(series) - # Retrieving the postgres user password using the action. + # Retrieving the operator user password using the action. action = await ops_test.model.units.get(f"{application_name}/{unit_id}").run_action( "get-initial-password" ) action = await action.wait() - password = action.results["postgres-password"] + password = action.results["operator-password"] # Connect to PostgreSQL. host = get_unit_address(ops_test, f"{application_name}/{unit_id}") @@ -222,7 +222,7 @@ async def test_persist_data_through_primary_deletion(ops_test: OpsTest, series: application_name = build_application_name(series) any_unit_name = ops_test.model.applications[application_name].units[0].name primary = await get_primary(ops_test, any_unit_name) - password = await get_postgres_password(ops_test, primary) + password = await get_operator_password(ops_test, primary) # Write data to primary IP. host = get_unit_address(ops_test, primary) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 2e5fea00d2..5e8b8a700e 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -118,12 +118,12 @@ def test_on_leader_elected( self, _update_cluster_members, _primary_endpoint, _update_endpoints ): # Assert that there is no password in the peer relation. - self.assertIsNone(self.charm._peers.data[self.charm.app].get("postgres-password", None)) + self.assertIsNone(self.charm._peers.data[self.charm.app].get("operator-password", None)) # Check that a new password was generated on leader election. _primary_endpoint.return_value = "1.1.1.1" self.harness.set_leader() - password = self.charm._peers.data[self.charm.app].get("postgres-password", None) + password = self.charm._peers.data[self.charm.app].get("operator-password", None) _update_cluster_members.assert_called_once() _update_endpoints.assert_not_called() self.assertIsNotNone(password) @@ -136,7 +136,7 @@ def test_on_leader_elected( self.harness.set_leader(False) self.harness.set_leader() self.assertEqual( - self.charm._peers.data[self.charm.app].get("postgres-password", None), password + self.charm._peers.data[self.charm.app].get("operator-password", None), password ) _update_endpoints.assert_called_once() self.assertFalse(isinstance(self.harness.model.unit.status, BlockedStatus)) @@ -154,10 +154,10 @@ def test_on_leader_elected( @patch("charm.Patroni.member_started") @patch("charm.Patroni.bootstrap_cluster") @patch("charm.PostgresqlOperatorCharm._replication_password") - @patch("charm.PostgresqlOperatorCharm._get_postgres_password") + @patch("charm.PostgresqlOperatorCharm._get_operator_password") def test_on_start( self, - _get_postgres_password, + _get_operator_password, _replication_password, _bootstrap_cluster, _member_started, @@ -165,13 +165,13 @@ def test_on_start( __, ): # Test before the passwords are generated. - _get_postgres_password.return_value = None + _get_operator_password.return_value = None self.charm.on.start.emit() _bootstrap_cluster.assert_not_called() self.assertTrue(isinstance(self.harness.model.unit.status, WaitingStatus)) # Mock the passwords. - _get_postgres_password.return_value = "fake-postgres-password" + _get_operator_password.return_value = "fake-operator-password" _replication_password.return_value = "fake-replication-password" # Mock cluster start success values. @@ -193,9 +193,9 @@ def test_on_start( @patch("charm.Patroni.bootstrap_cluster") @patch("charm.PostgresqlOperatorCharm._replication_password") - @patch("charm.PostgresqlOperatorCharm._get_postgres_password") + @patch("charm.PostgresqlOperatorCharm._get_operator_password") def test_on_start_after_blocked_state( - self, _get_postgres_password, _replication_password, _bootstrap_cluster + self, _get_operator_password, _replication_password, _bootstrap_cluster ): # Set an initial blocked status (like after the install hook was triggered). initial_status = BlockedStatus("fake message") @@ -203,30 +203,30 @@ def test_on_start_after_blocked_state( # Test for a failed cluster bootstrapping. self.charm.on.start.emit() - _get_postgres_password.assert_not_called() + _get_operator_password.assert_not_called() _replication_password.assert_not_called() _bootstrap_cluster.assert_not_called() # Assert the status didn't change. self.assertEqual(self.harness.model.unit.status, initial_status) - @patch("charm.PostgresqlOperatorCharm._get_postgres_password") - def test_on_get_postgres_password(self, _get_postgres_password): + @patch("charm.PostgresqlOperatorCharm._get_operator_password") + def test_on_get_operator_password(self, _get_operator_password): mock_event = Mock() - _get_postgres_password.return_value = "test-password" + _get_operator_password.return_value = "test-password" self.charm._on_get_initial_password(mock_event) - _get_postgres_password.assert_called_once() - mock_event.set_results.assert_called_once_with({"postgres-password": "test-password"}) + _get_operator_password.assert_called_once() + mock_event.set_results.assert_called_once_with({"operator-password": "test-password"}) @patch("charm.PostgreSQLProvider.update_endpoints") @patch("charm.Patroni.update_cluster_members") @patch_network_get(private_address="1.1.1.1") - def test_get_postgres_password(self, _, __): + def test_get_operator_password(self, _, __): # Test for a None password. - self.assertIsNone(self.charm._get_postgres_password()) + self.assertIsNone(self.charm._get_operator_password()) # Then test for a non empty password after leader election and peer data set. self.harness.set_leader() - password = self.charm._get_postgres_password() + password = self.charm._get_operator_password() self.assertIsNotNone(password) self.assertNotEqual(password, "") From 37c06399e1a2ce55e7fa52687053c8b04cb756fc Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 16 Aug 2022 13:17:17 -0300 Subject: [PATCH 02/34] Fix unit tests --- tests/unit/test_charm.py | 2 +- tests/unit/test_cluster.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 5e8b8a700e..2f873dcd7c 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -213,7 +213,7 @@ def test_on_start_after_blocked_state( def test_on_get_operator_password(self, _get_operator_password): mock_event = Mock() _get_operator_password.return_value = "test-password" - self.charm._on_get_initial_password(mock_event) + self.charm._on_get_operator_password(mock_event) _get_operator_password.assert_called_once() mock_event.set_results.assert_called_once_with({"operator-password": "test-password"}) diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 566eb607f8..2f5009d572 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -110,6 +110,7 @@ def test_render_patroni_yml_file(self, _, _render_file): peers_ips=self.peers_ips, scope=scope, self_ip=self.patroni.unit_ip, + superuser="operator", superuser_password=superuser_password, replication_password=replication_password, version=self.patroni._get_postgresql_version(), From d05d991b83aed9d7860ec19beed1671753f60279 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 16 Aug 2022 15:07:37 -0300 Subject: [PATCH 03/34] Fix integration test call --- tests/integration/test_charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 9d32a78044..e503097523 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -87,7 +87,7 @@ async def test_settings_are_correct(ops_test: OpsTest, series: str, unit_id: int # Retrieving the operator user password using the action. action = await ops_test.model.units.get(f"{application_name}/{unit_id}").run_action( - "get-initial-password" + "get-operator-password" ) action = await action.wait() password = action.results["operator-password"] From aa61f60cd017cdb28e4238493747beb754227581 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 17 Aug 2022 14:30:14 -0300 Subject: [PATCH 04/34] Fix user name in library --- lib/charms/postgresql_k8s/v0/postgresql.py | 4 +++- tests/integration/helpers.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/charms/postgresql_k8s/v0/postgresql.py b/lib/charms/postgresql_k8s/v0/postgresql.py index f901204051..524d5f8f63 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql.py +++ b/lib/charms/postgresql_k8s/v0/postgresql.py @@ -155,7 +155,9 @@ def delete_user(self, user: str) -> None: with self._connect_to_database( database ) as connection, connection.cursor() as cursor: - cursor.execute(sql.SQL("REASSIGN OWNED BY {} TO operator;").format(sql.Identifier(user))) + cursor.execute(sql.SQL("REASSIGN OWNED BY {} TO {};").format( + sql.Identifier(user), sql.Identifier(self.user) + )) cursor.execute(sql.SQL("DROP OWNED BY {};").format(sql.Identifier(user))) # Delete the user. diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 9bafa94f88..fee9125cb5 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -68,7 +68,7 @@ async def check_database_users_existence( """ unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] unit_address = await unit.get_public_address() - password = await get_postgres_password(ops_test, unit.name) + password = await get_operator_password(ops_test, unit.name) # Retrieve all users in the database. users_in_db = await execute_query_on_unit( @@ -94,7 +94,7 @@ async def check_databases_creation(ops_test: OpsTest, databases: List[str]) -> N databases: List of database names that should have been created """ unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] - password = await get_postgres_password(ops_test, unit.name) + password = await get_operator_password(ops_test, unit.name) for unit in ops_test.model.applications[DATABASE_APP_NAME].units: unit_address = await unit.get_public_address() From 991de5f19792a06d02730e3dfcbebc8ea5ce0281 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 17 Aug 2022 16:09:51 -0300 Subject: [PATCH 05/34] Fix user --- tests/integration/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index fee9125cb5..baf6a42537 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -290,7 +290,7 @@ async def execute_query_on_unit( A list of rows that were potentially returned from the query. """ with psycopg2.connect( - f"dbname='{database}' user='postgres' host='{unit_address}' password='{password}' connect_timeout=10" + f"dbname='{database}' user='operator' host='{unit_address}' password='{password}' connect_timeout=10" ) as connection, connection.cursor() as cursor: cursor.execute(query) output = list(itertools.chain(*cursor.fetchall())) From db18ba634d6febeab74f88c3acb338d55bb3501a Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 25 Aug 2022 09:35:35 -0300 Subject: [PATCH 06/34] Add default postgres user creation --- src/charm.py | 11 ++++++++++- tests/unit/test_charm.py | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/charm.py b/src/charm.py index b7883d9086..d24ce1e570 100755 --- a/src/charm.py +++ b/src/charm.py @@ -10,7 +10,7 @@ from typing import List, Optional, Set from charms.operator_libs_linux.v0 import apt -from charms.postgresql_k8s.v0.postgresql import PostgreSQL +from charms.postgresql_k8s.v0.postgresql import PostgreSQL, PostgreSQLCreateUserError from ops.charm import ( ActionEvent, CharmBase, @@ -555,6 +555,15 @@ def _on_start(self, event) -> None: event.defer() return + # Create the default postgres database user that is needed for some + # applications (not charms) like Landscape Server. + try: + self.postgresql.create_user("postgres", new_password(), admin=True) + except PostgreSQLCreateUserError as e: + logger.exception(e) + self.unit.status = BlockedStatus("Failed to create postgres user") + return + self.postgresql_client_relation.oversee_users() # Set the flag to enable the replicas to start the Patroni service. diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index e7aee50e5d..ea7b323e1c 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, PropertyMock, call, mock_open, patch from charms.operator_libs_linux.v0 import apt +from charms.postgresql_k8s.v0.postgresql import PostgreSQLCreateUserError from ops.model import ActiveStatus, BlockedStatus, WaitingStatus from ops.testing import Harness @@ -150,6 +151,7 @@ def test_on_leader_elected( @patch_network_get(private_address="1.1.1.1") @patch("charm.PostgreSQLProvider.oversee_users") + @patch("charm.PostgresqlOperatorCharm.postgresql") @patch("charm.PostgreSQLProvider.update_endpoints") @patch("charm.Patroni.update_cluster_members") @patch("charm.Patroni.member_started") @@ -164,6 +166,7 @@ def test_on_start( _member_started, _, __, + _postgresql, _oversee_users, ): # Test before the passwords are generated. @@ -176,8 +179,9 @@ def test_on_start( _get_operator_password.return_value = "fake-operator-password" _replication_password.return_value = "fake-replication-password" - # Mock cluster start success values. - _bootstrap_cluster.side_effect = [False, True] + # Mock cluster start and postgres user creation success values. + _bootstrap_cluster.side_effect = [False, True, True] + _postgresql.create_user.side_effect = [PostgreSQLCreateUserError, None] # Test for a failed cluster bootstrapping. # TODO: test replicas start (DPE-494). @@ -190,8 +194,20 @@ def test_on_start( # Set an initial waiting status (like after the install hook was triggered). self.harness.model.unit.status = WaitingStatus("fake message") + # Test the event of an error happening when trying to create the default postgres user. + self.charm.on.start.emit() + _postgresql.create_user.assert_called_once() + _oversee_users.assert_not_called() + self.assertTrue(isinstance(self.harness.model.unit.status, BlockedStatus)) + + # Set an initial waiting status again (like after the install hook was triggered). + self.harness.model.unit.status = WaitingStatus("fake message") + # Then test the event of a correct cluster bootstrapping. self.charm.on.start.emit() + self.assertEqual( + _postgresql.create_user.call_count, 2 + ) # Considering the previous failed call. _oversee_users.assert_called_once() self.assertTrue(isinstance(self.harness.model.unit.status, ActiveStatus)) From 61946dab2e656bec4ad5a7d1a5c3c73dd7204700 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 25 Aug 2022 10:14:00 -0300 Subject: [PATCH 07/34] Change action name --- actions.yaml | 2 +- src/charm.py | 16 +++++++--------- tests/integration/helpers.py | 8 ++++---- tests/integration/test_charm.py | 11 ++++------- tests/unit/test_charm.py | 30 +++++++++++++++--------------- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/actions.yaml b/actions.yaml index f465bd1a91..7f02b9e9e4 100644 --- a/actions.yaml +++ b/actions.yaml @@ -3,6 +3,6 @@ get-primary: description: Get the unit which is the primary/leader in the replication. -get-operator-password: +get-password: description: Get the operator user password used by charm. It is internal charm user, SHOULD NOT be used by applications. diff --git a/src/charm.py b/src/charm.py index d24ce1e570..3bc82ce0d4 100755 --- a/src/charm.py +++ b/src/charm.py @@ -63,9 +63,7 @@ def __init__(self, *args): self.framework.observe(self.on[PEER].relation_departed, self._on_peer_relation_departed) self.framework.observe(self.on.pgdata_storage_detaching, self._on_pgdata_storage_detaching) self.framework.observe(self.on.start, self._on_start) - self.framework.observe( - self.on.get_operator_password_action, self._on_get_operator_password - ) + self.framework.observe(self.on.get_password_action, self._on_get_password) self.framework.observe(self.on.update_status, self._on_update_status) self._cluster_name = self.app.name self._member_name = self.unit.name.replace("/", "-") @@ -81,7 +79,7 @@ def postgresql(self) -> PostgreSQL: return PostgreSQL( host=self.primary_endpoint, user=USER, - password=self._get_operator_password(), + password=self._get_password(), database="postgres", ) @@ -337,7 +335,7 @@ def _patroni(self) -> Patroni: self._member_name, self.app.planned_units(), self._peer_members_ips, - self._get_operator_password(), + self._get_password(), self._replication_password, ) @@ -524,7 +522,7 @@ def _on_start(self, event) -> None: if self._has_blocked_status: return - postgres_password = self._get_operator_password() + postgres_password = self._get_password() # If the leader was not elected (and the needed passwords were not generated yet), # the cluster cannot be bootstrapped yet. if not postgres_password or not self._replication_password: @@ -570,9 +568,9 @@ def _on_start(self, event) -> None: self._peers.data[self.app]["cluster_initialised"] = "True" self.unit.status = ActiveStatus() - def _on_get_operator_password(self, event: ActionEvent) -> None: + def _on_get_password(self, event: ActionEvent) -> None: """Returns the password for the operator user as an action response.""" - event.set_results({"operator-password": self._get_operator_password()}) + event.set_results({"operator-password": self._get_password()}) def _on_update_status(self, _) -> None: """Update endpoints of the postgres client relation and update users list.""" @@ -586,7 +584,7 @@ def _has_blocked_status(self) -> bool: """Returns whether the unit is in a blocked state.""" return isinstance(self.unit.status, BlockedStatus) - def _get_operator_password(self) -> str: + def _get_password(self) -> str: """Get operator user password. Returns: diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index baf6a42537..91ce885196 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -68,7 +68,7 @@ async def check_database_users_existence( """ unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] unit_address = await unit.get_public_address() - password = await get_operator_password(ops_test, unit.name) + password = await get_password(ops_test, unit.name) # Retrieve all users in the database. users_in_db = await execute_query_on_unit( @@ -94,7 +94,7 @@ async def check_databases_creation(ops_test: OpsTest, databases: List[str]) -> N databases: List of database names that should have been created """ unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] - password = await get_operator_password(ops_test, unit.name) + password = await get_password(ops_test, unit.name) for unit in ops_test.model.applications[DATABASE_APP_NAME].units: unit_address = await unit.get_public_address() @@ -344,7 +344,7 @@ def get_application_units_ips(ops_test: OpsTest, application_name: str) -> List[ return [unit.public_address for unit in ops_test.model.applications[application_name].units] -async def get_operator_password(ops_test: OpsTest, unit_name: str) -> str: +async def get_password(ops_test: OpsTest, unit_name: str) -> str: """Retrieve the operator user password using the action. Args: @@ -355,7 +355,7 @@ async def get_operator_password(ops_test: OpsTest, unit_name: str) -> str: the operator user password. """ unit = ops_test.model.units.get(unit_name) - action = await unit.run_action("get-operator-password") + action = await unit.run_action("get-password") result = await action.wait() return result.results["operator-password"] diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index a689f297aa..09e0a16e3b 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -19,7 +19,7 @@ convert_records_to_dict, db_connect, find_unit, - get_operator_password, + get_password, get_primary, get_unit_address, scale_application, @@ -79,11 +79,8 @@ async def test_settings_are_correct(ops_test: OpsTest, series: str, unit_id: int application_name = build_application_name(series) # Retrieving the operator user password using the action. - action = await ops_test.model.units.get(f"{application_name}/{unit_id}").run_action( - "get-operator-password" - ) - action = await action.wait() - password = action.results["operator-password"] + any_unit_name = ops_test.model.applications[application_name].units[0].name + password = await get_password(ops_test, any_unit_name) # Connect to PostgreSQL. host = get_unit_address(ops_test, f"{application_name}/{unit_id}") @@ -215,7 +212,7 @@ async def test_persist_data_through_primary_deletion(ops_test: OpsTest, series: application_name = build_application_name(series) any_unit_name = ops_test.model.applications[application_name].units[0].name primary = await get_primary(ops_test, any_unit_name) - password = await get_operator_password(ops_test, primary) + password = await get_password(ops_test, primary) # Write data to primary IP. host = get_unit_address(ops_test, primary) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index ea7b323e1c..5e69bf0a8e 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -157,10 +157,10 @@ def test_on_leader_elected( @patch("charm.Patroni.member_started") @patch("charm.Patroni.bootstrap_cluster") @patch("charm.PostgresqlOperatorCharm._replication_password") - @patch("charm.PostgresqlOperatorCharm._get_operator_password") + @patch("charm.PostgresqlOperatorCharm._get_password") def test_on_start( self, - _get_operator_password, + _get_password, _replication_password, _bootstrap_cluster, _member_started, @@ -170,13 +170,13 @@ def test_on_start( _oversee_users, ): # Test before the passwords are generated. - _get_operator_password.return_value = None + _get_password.return_value = None self.charm.on.start.emit() _bootstrap_cluster.assert_not_called() self.assertTrue(isinstance(self.harness.model.unit.status, WaitingStatus)) # Mock the passwords. - _get_operator_password.return_value = "fake-operator-password" + _get_password.return_value = "fake-operator-password" _replication_password.return_value = "fake-replication-password" # Mock cluster start and postgres user creation success values. @@ -213,9 +213,9 @@ def test_on_start( @patch("charm.Patroni.bootstrap_cluster") @patch("charm.PostgresqlOperatorCharm._replication_password") - @patch("charm.PostgresqlOperatorCharm._get_operator_password") + @patch("charm.PostgresqlOperatorCharm._get_password") def test_on_start_after_blocked_state( - self, _get_operator_password, _replication_password, _bootstrap_cluster + self, _get_password, _replication_password, _bootstrap_cluster ): # Set an initial blocked status (like after the install hook was triggered). initial_status = BlockedStatus("fake message") @@ -223,30 +223,30 @@ def test_on_start_after_blocked_state( # Test for a failed cluster bootstrapping. self.charm.on.start.emit() - _get_operator_password.assert_not_called() + _get_password.assert_not_called() _replication_password.assert_not_called() _bootstrap_cluster.assert_not_called() # Assert the status didn't change. self.assertEqual(self.harness.model.unit.status, initial_status) - @patch("charm.PostgresqlOperatorCharm._get_operator_password") - def test_on_get_operator_password(self, _get_operator_password): + @patch("charm.PostgresqlOperatorCharm._get_password") + def test_on_get_password(self, _get_password): mock_event = Mock() - _get_operator_password.return_value = "test-password" - self.charm._on_get_operator_password(mock_event) - _get_operator_password.assert_called_once() + _get_password.return_value = "test-password" + self.charm._on_get_password(mock_event) + _get_password.assert_called_once() mock_event.set_results.assert_called_once_with({"operator-password": "test-password"}) @patch("charm.PostgreSQLProvider.update_endpoints") @patch("charm.Patroni.update_cluster_members") @patch_network_get(private_address="1.1.1.1") - def test_get_operator_password(self, _, __): + def test_get_password(self, _, __): # Test for a None password. - self.assertIsNone(self.charm._get_operator_password()) + self.assertIsNone(self.charm._get_password()) # Then test for a non empty password after leader election and peer data set. self.harness.set_leader() - password = self.charm._get_operator_password() + password = self.charm._get_password() self.assertIsNotNone(password) self.assertNotEqual(password, "") From 5f01c6e0ec5d3133aa8dcb992dfe40bbc7fb0e9f Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 25 Aug 2022 14:44:59 -0300 Subject: [PATCH 08/34] Rework secrets management --- src/charm.py | 63 +++++++++++++++++++++++++++++++++------- src/constants.py | 2 ++ tests/unit/test_charm.py | 42 ++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/charm.py b/src/charm.py index 3bc82ce0d4..b09a6f87b1 100755 --- a/src/charm.py +++ b/src/charm.py @@ -7,7 +7,7 @@ import logging import os import subprocess -from typing import List, Optional, Set +from typing import Dict, List, Optional, Set from charms.operator_libs_linux.v0 import apt from charms.postgresql_k8s.v0.postgresql import PostgreSQL, PostgreSQLCreateUserError @@ -37,7 +37,7 @@ RemoveRaftMemberFailedError, SwitchoverFailedError, ) -from constants import PEER, USER +from constants import PEER, REPLICATION_PASSWORD_KEY, USER, USER_PASSWORD_KEY from relations.db import DbProvides from relations.postgresql_provider import PostgreSQLProvider from utils import new_password @@ -73,6 +73,48 @@ def __init__(self, *args): self.legacy_db_relation = DbProvides(self, admin=False) self.legacy_db_admin_relation = DbProvides(self, admin=True) + @property + def app_peer_data(self) -> Dict: + """Application peer relation data object.""" + relation = self.model.get_relation(PEER) + if relation is None: + return {} + + return relation.data[self.app] + + @property + def unit_peer_data(self) -> Dict: + """Unit peer relation data object.""" + relation = self.model.get_relation(PEER) + if relation is None: + return {} + + return relation.data[self.unit] + + def _get_secret(self, scope: str, key: str) -> Optional[str]: + """Get secret from the secret storage.""" + if scope == "unit": + return self.unit_peer_data.get(key, None) + elif scope == "app": + return self.app_peer_data.get(key, None) + else: + raise RuntimeError("Unknown secret scope.") + + def _set_secret(self, scope: str, key: str, value: Optional[str]) -> None: + """Get secret from the secret storage.""" + if scope == "unit": + if not value: + del self.unit_peer_data[key] + return + self.unit_peer_data.update({key: value}) + elif scope == "app": + if not value: + del self.app_peer_data[key] + return + self.app_peer_data.update({key: value}) + else: + raise RuntimeError("Unknown secret scope.") + @property def postgresql(self) -> PostgreSQL: """Returns an instance of the object used to interact with the database.""" @@ -468,10 +510,11 @@ def _inhibit_default_cluster_creation(self) -> None: def _on_leader_elected(self, event: LeaderElectedEvent) -> None: """Handle the leader-elected event.""" - data = self._peers.data[self.app] - # The leader sets the needed password on peer relation databag if they weren't set before. - data.setdefault("operator-password", new_password()) - data.setdefault("replication-password", new_password()) + # The leader sets the needed passwords if they weren't set before. + if self._get_secret("app", USER_PASSWORD_KEY) is None: + self._set_secret("app", USER_PASSWORD_KEY, new_password()) + if self._get_secret("app", REPLICATION_PASSWORD_KEY) is None: + self._set_secret("app", REPLICATION_PASSWORD_KEY, new_password()) # Update the list of the current PostgreSQL hosts when a new leader is elected. # Add this unit to the list of cluster members @@ -570,7 +613,7 @@ def _on_start(self, event) -> None: def _on_get_password(self, event: ActionEvent) -> None: """Returns the password for the operator user as an action response.""" - event.set_results({"operator-password": self._get_password()}) + event.set_results({USER_PASSWORD_KEY: self._get_password()}) def _on_update_status(self, _) -> None: """Update endpoints of the postgres client relation and update users list.""" @@ -591,8 +634,7 @@ def _get_password(self) -> str: The password from the peer relation or None if the password has not yet been set by the leader. """ - data = self._peers.data[self.app] - return data.get("operator-password") + return self._get_secret("app", USER_PASSWORD_KEY) @property def _replication_password(self) -> str: @@ -602,8 +644,7 @@ def _replication_password(self) -> str: The password from the peer relation or None if the password has not yet been set by the leader. """ - data = self._peers.data[self.app] - return data.get("replication-password") + return self._get_secret("app", REPLICATION_PASSWORD_KEY) def _install_apt_packages(self, _, packages: List[str]) -> None: """Simple wrapper around 'apt-get install -y. diff --git a/src/constants.py b/src/constants.py index 1dea2ef46c..8fcc127f29 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,4 +9,6 @@ LEGACY_DB_ADMIN = "db-admin" PEER = "database-peers" ALL_CLIENT_RELATIONS = [DATABASE, LEGACY_DB, LEGACY_DB_ADMIN] +REPLICATION_PASSWORD_KEY = "replication-password" USER = "operator" +USER_PASSWORD_KEY = "operator-password" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 5e69bf0a8e..41fd7ac032 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -27,7 +27,7 @@ def setUp(self): self.addCleanup(self.harness.cleanup) self.harness.begin() self.charm = self.harness.charm - self.harness.add_relation(self._peer_relation, self.charm.app.name) + self.rel_id = self.harness.add_relation(self._peer_relation, self.charm.app.name) @patch_network_get(private_address="1.1.1.1") @patch("charm.PostgresqlOperatorCharm._install_pip_packages") @@ -305,3 +305,43 @@ def test_install_pip_packages(self, _call): # Then, test for an error. with self.assertRaises(subprocess.SubprocessError): self.charm._install_pip_packages(packages) + + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + def test_get_secret(self, _): + self.harness.set_leader() + + # Test application scope. + assert self.charm._get_secret("app", "password") is None + self.harness.update_relation_data( + self.rel_id, self.charm.app.name, {"password": "test-password"} + ) + assert self.charm._get_secret("app", "password") == "test-password" + + # Test unit scope. + assert self.charm._get_secret("unit", "password") is None + self.harness.update_relation_data( + self.rel_id, self.charm.unit.name, {"password": "test-password"} + ) + assert self.charm._get_secret("unit", "password") == "test-password" + + @patch_network_get(private_address="1.1.1.1") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + def test_set_secret(self, _): + self.harness.set_leader() + + # Test application scope. + assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.app.name) + self.charm._set_secret("app", "password", "test-password") + assert ( + self.harness.get_relation_data(self.rel_id, self.charm.app.name)["password"] + == "test-password" + ) + + # Test unit scope. + assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.unit.name) + self.charm._set_secret("unit", "password", "test-password") + assert ( + self.harness.get_relation_data(self.rel_id, self.charm.unit.name)["password"] + == "test-password" + ) From aa14718c194ad10600b553565866a8a4c7fb4f05 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 26 Aug 2022 10:05:16 -0300 Subject: [PATCH 09/34] Add password rotation logic --- actions.yaml | 17 ++- lib/charms/postgresql_k8s/v0/postgresql.py | 28 ++++- src/charm.py | 86 ++++++++++++++- src/cluster.py | 10 +- src/constants.py | 3 + tests/integration/helpers.py | 58 +++++++++- tests/integration/test_password_rotation.py | 68 ++++++++++++ tests/unit/test_charm.py | 115 +++++++++++++++++--- tests/unit/test_cluster.py | 2 +- 9 files changed, 353 insertions(+), 34 deletions(-) create mode 100644 tests/integration/test_password_rotation.py diff --git a/actions.yaml b/actions.yaml index 7f02b9e9e4..af012a3fe2 100644 --- a/actions.yaml +++ b/actions.yaml @@ -4,5 +4,18 @@ get-primary: description: Get the unit which is the primary/leader in the replication. get-password: - description: Get the operator user password used by charm. - It is internal charm user, SHOULD NOT be used by applications. + description: Change the system user's password, which is used by charm. + It is for internal charm users and SHOULD NOT be used by applications. + params: + username: + type: string + description: The username, the default value 'operator'. + Possible values - operator, replication. +set-password: + description: Change the system user's password, which is used by charm. + It is for internal charm users and SHOULD NOT be used by applications. + params: + username: + type: string + description: The username, the default value 'operator'. + Possible values - operator, replication. diff --git a/lib/charms/postgresql_k8s/v0/postgresql.py b/lib/charms/postgresql_k8s/v0/postgresql.py index 524d5f8f63..6e87561a5e 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql.py +++ b/lib/charms/postgresql_k8s/v0/postgresql.py @@ -32,7 +32,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 1 +LIBPATCH = 2 logger = logging.getLogger(__name__) @@ -58,6 +58,10 @@ class PostgreSQLListUsersError(Exception): """Exception raised when retrieving PostgreSQL users list fails.""" +class PostgreSQLUpdateUserPasswordError(Exception): + """Exception raised when updating a user password fails.""" + + class PostgreSQL: """Class to encapsulate all operations related to interacting with PostgreSQL instance.""" @@ -196,3 +200,25 @@ def list_users(self) -> Set[str]: except psycopg2.Error as e: logger.error(f"Failed to list PostgreSQL database users: {e}") raise PostgreSQLListUsersError() + + def update_user_password(self, username: str, password: str) -> None: + """Update a user password. + Args: + username: the user to update the password. + password: the new password for the user. + Raises: + PostgreSQLUpdateUserPasswordError if the password couldn't be changed. + """ + try: + with self._connect_to_database() as connection, connection.cursor() as cursor: + cursor.execute( + sql.SQL("ALTER USER {} WITH ENCRYPTED PASSWORD '" + password + "';").format( + sql.Identifier(username) + ) + ) + except psycopg2.Error as e: + logger.error(f"Failed to update user password: {e}") + raise PostgreSQLUpdateUserPasswordError() + finally: + if connection is not None: + connection.close() diff --git a/src/charm.py b/src/charm.py index b09a6f87b1..31b93b6e15 100755 --- a/src/charm.py +++ b/src/charm.py @@ -10,7 +10,11 @@ from typing import Dict, List, Optional, Set from charms.operator_libs_linux.v0 import apt -from charms.postgresql_k8s.v0.postgresql import PostgreSQL, PostgreSQLCreateUserError +from charms.postgresql_k8s.v0.postgresql import ( + PostgreSQL, + PostgreSQLCreateUserError, + PostgreSQLUpdateUserPasswordError, +) from ops.charm import ( ActionEvent, CharmBase, @@ -37,7 +41,13 @@ RemoveRaftMemberFailedError, SwitchoverFailedError, ) -from constants import PEER, REPLICATION_PASSWORD_KEY, USER, USER_PASSWORD_KEY +from constants import ( + PEER, + REPLICATION_PASSWORD_KEY, + SYSTEM_USERS, + USER, + USER_PASSWORD_KEY, +) from relations.db import DbProvides from relations.postgresql_provider import PostgreSQLProvider from utils import new_password @@ -64,6 +74,7 @@ def __init__(self, *args): self.framework.observe(self.on.pgdata_storage_detaching, self._on_pgdata_storage_detaching) self.framework.observe(self.on.start, self._on_start) self.framework.observe(self.on.get_password_action, self._on_get_password) + self.framework.observe(self.on.set_password_action, self._on_set_password) self.framework.observe(self.on.update_status, self._on_update_status) self._cluster_name = self.app.name self._member_name = self.unit.name.replace("/", "-") @@ -121,7 +132,7 @@ def postgresql(self) -> PostgreSQL: return PostgreSQL( host=self.primary_endpoint, user=USER, - password=self._get_password(), + password=self._get_secret("app", f"{USER}-password"), database="postgres", ) @@ -612,8 +623,73 @@ def _on_start(self, event) -> None: self.unit.status = ActiveStatus() def _on_get_password(self, event: ActionEvent) -> None: - """Returns the password for the operator user as an action response.""" - event.set_results({USER_PASSWORD_KEY: self._get_password()}) + """Returns the password for a user as an action response. + + If no user is provided, the password of the operator user is returned. + """ + username = event.params.get("username", USER) + if username not in SYSTEM_USERS: + event.fail( + f"The action can be run only for users used by the charm or Patroni:" + f" {', '.join(SYSTEM_USERS)} not {username}" + ) + return + event.set_results( + {f"{username}-password": self._get_secret("app", f"{username}-password")} + ) + + def _on_set_password(self, event: ActionEvent) -> None: + """Set the password for the specified user.""" + # Only leader can write the new password into peer relation. + if not self.unit.is_leader(): + event.fail("The action can be run only on leader unit") + return + + username = event.params.get("username", USER) + if username not in SYSTEM_USERS: + event.fail( + f"The action can be run only for users used by the charm:" + f" {', '.join(SYSTEM_USERS)} not {username}" + ) + return + + password = new_password() + if "password" in event.params: + password = event.params["password"] + + if password == self._get_secret("app", f"{username}-password"): + event.log("The old and new passwords are equal.") + event.set_results({f"{username}-password": password}) + return + + # Ensure all members are ready before trying to reload Patroni + # configuration to avoid errors (like the API not responding in + # one instance because PostgreSQL and/or Patroni are not ready). + if not self._patroni.are_all_members_ready(): + event.fail( + "Failed changing the password: Not all members healthy or finished initial sync." + ) + return + + # Update the password in the PostgreSQL instance. + try: + self.postgresql.update_user_password(username, password) + except PostgreSQLUpdateUserPasswordError as e: + logger.exception(e) + event.fail( + "Failed changing the password: Not all members healthy or finished initial sync." + ) + return + + # Update the password in the secret store. + self._set_secret("app", f"{username}-password", password) + + # Update and reload Patroni configuration in this unit to use the new password. + # Other units Patroni configuration will be reloaded in the peer relation changed event. + self._patroni.render_patroni_yml_file() + self._patroni.reload_patroni_configuration() + + event.set_results({f"{username}-password": password}) def _on_update_status(self, _) -> None: """Update endpoints of the postgres client relation and update users list.""" diff --git a/src/cluster.py b/src/cluster.py index 03ece3d92f..9c28b19119 100644 --- a/src/cluster.py +++ b/src/cluster.py @@ -98,7 +98,7 @@ def configure_patroni_on_unit(self, replica: bool = False): (defaults to False, which configures the unit as a leader) """ self._change_owner(self.storage_path) - self._render_patroni_yml_file(replica) + self.render_patroni_yml_file(replica) self._render_patroni_service_file() # Reload systemd services before trying to start Patroni. daemon_reload() @@ -248,7 +248,7 @@ def _render_patroni_service_file(self) -> None: rendered = template.render(conf_path=self.storage_path) self._render_file("/etc/systemd/system/patroni.service", rendered, 0o644) - def _render_patroni_yml_file(self, replica: bool = False) -> None: + def render_patroni_yml_file(self, replica: bool = False) -> None: """Render the Patroni configuration file.""" # Open the template patroni.yml file. with open("templates/patroni.yml.j2", "r") as file: @@ -320,10 +320,10 @@ def primary_changed(self, old_primary: str) -> bool: def update_cluster_members(self) -> None: """Update the list of members of the cluster.""" # Update the members in the Patroni configuration. - self._render_patroni_yml_file() + self.render_patroni_yml_file() if service_running(PATRONI_SERVICE): - self._reload_patroni_configuration() + self.reload_patroni_configuration() def remove_raft_member(self, member_ip: str) -> None: """Remove a member from the raft cluster. @@ -353,6 +353,6 @@ def remove_raft_member(self, member_ip: str) -> None: raise RemoveRaftMemberFailedError() @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) - def _reload_patroni_configuration(self): + def reload_patroni_configuration(self): """Reload Patroni configuration after it was changed.""" requests.post(f"http://{self.unit_ip}:8008/reload") diff --git a/src/constants.py b/src/constants.py index 8fcc127f29..1554176034 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,6 +9,9 @@ LEGACY_DB_ADMIN = "db-admin" PEER = "database-peers" ALL_CLIENT_RELATIONS = [DATABASE, LEGACY_DB, LEGACY_DB_ADMIN] +REPLICATION_USER = "replication" REPLICATION_PASSWORD_KEY = "replication-password" USER = "operator" USER_PASSWORD_KEY = "operator-password" +# List of system usernames needed for correct work of the charm/workload. +SYSTEM_USERS = [REPLICATION_USER, USER] diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 91ce885196..881aa1c4cb 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -4,6 +4,7 @@ import itertools import tempfile import zipfile +from datetime import datetime from pathlib import Path from typing import List @@ -12,7 +13,13 @@ import yaml from juju.unit import Unit from pytest_operator.plugin import OpsTest -from tenacity import Retrying, stop_after_attempt, wait_exponential +from tenacity import ( + Retrying, + retry, + retry_if_result, + stop_after_attempt, + wait_exponential, +) METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) DATABASE_APP_NAME = METADATA["name"] @@ -118,6 +125,30 @@ async def check_databases_creation(ops_test: OpsTest, databases: List[str]) -> N assert len(output) +@retry( + retry=retry_if_result(lambda x: not x), + stop=stop_after_attempt(10), + wait=wait_exponential(multiplier=1, min=2, max=30), +) +def check_patroni(ops_test: OpsTest, unit_name: str, restart_time: float) -> bool: + """Check if Patroni is running correctly on a specific unit. + + Args: + ops_test: The ops test framework instance + unit_name: The name of the unit + restart_time: Point in time before the unit was restarted. + + Returns: + whether Patroni is running correctly. + """ + unit_ip = get_unit_address(ops_test, unit_name) + health_info = requests.get(f"http://{unit_ip}:8008/health").json() + postmaster_start_time = datetime.strptime( + health_info["postmaster_start_time"], "%Y-%m-%d %H:%M:%S.%f%z" + ).timestamp() + return postmaster_start_time > restart_time and health_info["state"] == "running" + + def build_application_name(series: str) -> str: """Return a composite application name combining application name and series.""" return f"{DATABASE_APP_NAME}-{series}" @@ -344,15 +375,15 @@ def get_application_units_ips(ops_test: OpsTest, application_name: str) -> List[ return [unit.public_address for unit in ops_test.model.applications[application_name].units] -async def get_password(ops_test: OpsTest, unit_name: str) -> str: - """Retrieve the operator user password using the action. +async def get_password(ops_test: OpsTest, unit_name: str, username: str = "operator") -> str: + """Retrieve a user password using the action. Args: ops_test: ops_test instance. unit_name: the name of the unit. Returns: - the operator user password. + the user password. """ unit = ops_test.model.units.get(unit_name) action = await unit.run_action("get-password") @@ -409,6 +440,25 @@ async def scale_application(ops_test: OpsTest, application_name: str, count: int ) +def restart_patroni(ops_test: OpsTest, unit_name: str) -> None: + """Restart Patroni on a specific unit. + + Args: + ops_test: The ops test framework instance + unit_name: The name of the unit + """ + unit_ip = get_unit_address(ops_test, unit_name) + requests.post(f"http://{unit_ip}:8008/restart") + + +async def set_password(ops_test: OpsTest, unit_name: str, username: str = "operator"): + """Retrieve a user password using the action.""" + unit = ops_test.model.units.get(unit_name) + action = await unit.run_action("set-password", **{"username": username}) + result = await action.wait() + return result.results + + def switchover(ops_test: OpsTest, current_primary: str, candidate: str = None) -> None: """Trigger a switchover. diff --git a/tests/integration/test_password_rotation.py b/tests/integration/test_password_rotation.py new file mode 100644 index 0000000000..2f4e8ae4b5 --- /dev/null +++ b/tests/integration/test_password_rotation.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import time + +import pytest +from pytest_operator.plugin import OpsTest + +from tests.helpers import METADATA +from tests.integration.helpers import ( + check_patroni, + get_password, + restart_patroni, + set_password, +) + +APP_NAME = METADATA["name"] + + +@pytest.mark.skip_if_deployed +@pytest.mark.abort_on_fail +async def test_deploy_active(ops_test: OpsTest): + """Build the charm and deploy it.""" + charm = await ops_test.build_charm(".") + async with ops_test.fast_forward(): + await ops_test.model.deploy( + charm, resources={"patroni": "patroni.tar.gz"}, application_name=APP_NAME, num_units=3 + ) + await ops_test.juju("attach-resource", APP_NAME, "patroni=patroni.tar.gz") + await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) + + +async def test_password_rotation(ops_test: OpsTest): + """Test password rotation action.""" + # Get the initial passwords set for the system users. + any_unit_name = ops_test.model.applications[APP_NAME].units[0].name + superuser_password = await get_password(ops_test, any_unit_name) + replication_password = await get_password(ops_test, any_unit_name, "replication") + + # Get the leader unit name (because passwords can only be set through it). + leader = None + for unit in ops_test.model.applications[APP_NAME].units: + if await unit.is_leader_from_status(): + leader = unit.name + break + + # Change both passwords. + result = await set_password(ops_test, unit_name=leader) + assert "operator-password" in result.keys() + await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) + + result = await set_password(ops_test, unit_name=leader, username="replication") + assert "replication-password" in result.keys() + await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) + + new_superuser_password = await get_password(ops_test, any_unit_name) + new_replication_password = await get_password(ops_test, any_unit_name, "replication") + + assert superuser_password != new_superuser_password + assert replication_password != new_replication_password + + # Restart Patroni on any non-leader unit and check that + # Patroni and PostgreSQL continue to work. + restart_time = time.time() + for unit in ops_test.model.applications[APP_NAME].units: + if not await unit.is_leader_from_status(): + restart_patroni(ops_test, unit.name) + assert check_patroni(ops_test, unit.name, restart_time) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 41fd7ac032..3e8e429c60 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -3,10 +3,13 @@ import os import subprocess import unittest -from unittest.mock import Mock, PropertyMock, call, mock_open, patch +from unittest.mock import MagicMock, Mock, PropertyMock, call, mock_open, patch from charms.operator_libs_linux.v0 import apt -from charms.postgresql_k8s.v0.postgresql import PostgreSQLCreateUserError +from charms.postgresql_k8s.v0.postgresql import ( + PostgreSQLCreateUserError, + PostgreSQLUpdateUserPasswordError, +) from ops.model import ActiveStatus, BlockedStatus, WaitingStatus from ops.testing import Harness @@ -229,26 +232,106 @@ def test_on_start_after_blocked_state( # Assert the status didn't change. self.assertEqual(self.harness.model.unit.status, initial_status) - @patch("charm.PostgresqlOperatorCharm._get_password") - def test_on_get_password(self, _get_password): - mock_event = Mock() - _get_password.return_value = "test-password" + def test_on_get_password(self): + # Create a mock event and set passwords in peer relation data. + mock_event = MagicMock(params={}) + self.harness.update_relation_data( + self.rel_id, + self.charm.app.name, + { + "operator-password": "test-password", + "replication-password": "replication-test-password", + }, + ) + + # Test providing an invalid username. + mock_event.params["username"] = "user" + self.charm._on_get_password(mock_event) + mock_event.fail.assert_called_once() + mock_event.set_results.assert_not_called() + + # Test without providing the username option. + mock_event.reset_mock() + del mock_event.params["username"] self.charm._on_get_password(mock_event) - _get_password.assert_called_once() mock_event.set_results.assert_called_once_with({"operator-password": "test-password"}) - @patch("charm.PostgreSQLProvider.update_endpoints") - @patch("charm.Patroni.update_cluster_members") + # Also test providing the username option. + mock_event.reset_mock() + mock_event.params["username"] = "replication" + self.charm._on_get_password(mock_event) + mock_event.set_results.assert_called_once_with( + {"replication-password": "replication-test-password"} + ) + @patch_network_get(private_address="1.1.1.1") - def test_get_password(self, _, __): - # Test for a None password. - self.assertIsNone(self.charm._get_password()) + @patch("charm.Patroni.reload_patroni_configuration") + @patch("charm.Patroni.render_patroni_yml_file") + @patch("charm.PostgresqlOperatorCharm._set_secret") + @patch("charm.PostgresqlOperatorCharm.postgresql") + @patch("charm.Patroni.are_all_members_ready") + @patch("charm.PostgresqlOperatorCharm._on_leader_elected") + def test_on_set_password( + self, + _, + _are_all_members_ready, + _postgresql, + _set_secret, + _render_patroni_yml_file, + _reload_patroni_configuration, + ): + # Create a mock event. + mock_event = MagicMock(params={}) - # Then test for a non empty password after leader election and peer data set. + # Set some values for the other mocks. + _are_all_members_ready.side_effect = [False, True, True, True, True] + _postgresql.update_user_password = PropertyMock( + side_effect=[PostgreSQLUpdateUserPasswordError, None, None, None] + ) + + # Test trying to set a password through a non leader unit. + self.charm._on_set_password(mock_event) + mock_event.fail.assert_called_once() + _set_secret.assert_not_called() + + # Test providing an invalid username. self.harness.set_leader() - password = self.charm._get_password() - self.assertIsNotNone(password) - self.assertNotEqual(password, "") + mock_event.reset_mock() + mock_event.params["username"] = "user" + self.charm._on_set_password(mock_event) + mock_event.fail.assert_called_once() + _set_secret.assert_not_called() + + # Test without providing the username option but without all cluster members ready. + mock_event.reset_mock() + del mock_event.params["username"] + self.charm._on_set_password(mock_event) + mock_event.fail.assert_called_once() + _set_secret.assert_not_called() + + # Test for an error updating when updating the user password in the database. + mock_event.reset_mock() + self.charm._on_set_password(mock_event) + mock_event.fail.assert_called_once() + _set_secret.assert_not_called() + + # Test without providing the username option. + self.charm._on_set_password(mock_event) + self.assertEqual(_set_secret.call_args_list[0][0][1], "operator-password") + + # Also test providing the username option. + _set_secret.reset_mock() + mock_event.params["username"] = "replication" + self.charm._on_set_password(mock_event) + self.assertEqual(_set_secret.call_args_list[0][0][1], "replication-password") + + # And test providing both the username and password options. + _set_secret.reset_mock() + mock_event.params["password"] = "replication-test-password" + self.charm._on_set_password(mock_event) + _set_secret.assert_called_once_with( + "app", "replication-password", "replication-test-password" + ) @patch("charms.operator_libs_linux.v0.apt.add_package") @patch("charms.operator_libs_linux.v0.apt.update") diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 2f5009d572..6947cae694 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -123,7 +123,7 @@ def test_render_patroni_yml_file(self, _, _render_file): # Patch the `open` method with our mock. with patch("builtins.open", mock, create=True): # Call the method. - self.patroni._render_patroni_yml_file() + self.patroni.render_patroni_yml_file() # Check the template is opened read-only in the call to open. self.assertEqual(mock.call_args_list[0][0], ("templates/patroni.yml.j2", "r")) From 9e7fdfd7f85ed8ac9e84cb6500470ac168ac10a3 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 26 Aug 2022 10:52:11 -0300 Subject: [PATCH 10/34] Add user to the action parameters --- tests/integration/helpers.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 881aa1c4cb..08cebe8c74 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -381,14 +381,15 @@ async def get_password(ops_test: OpsTest, unit_name: str, username: str = "opera Args: ops_test: ops_test instance. unit_name: the name of the unit. + username: the user to get the password. Returns: the user password. """ unit = ops_test.model.units.get(unit_name) - action = await unit.run_action("get-password") + action = await unit.run_action("get-password", **{"username": username}) result = await action.wait() - return result.results["operator-password"] + return result.results[f"{username}-password"] async def get_primary(ops_test: OpsTest, unit_name: str) -> str: @@ -452,7 +453,16 @@ def restart_patroni(ops_test: OpsTest, unit_name: str) -> None: async def set_password(ops_test: OpsTest, unit_name: str, username: str = "operator"): - """Retrieve a user password using the action.""" + """Retrieve a user password using the action. + + Args: + ops_test: ops_test instance. + unit_name: the name of the unit. + username: the user to set the password. + + Returns: + the results from the action. + """ unit = ops_test.model.units.get(unit_name) action = await unit.run_action("set-password", **{"username": username}) result = await action.wait() From 12a4a7b85ac5b7dbb056eaaad017211bf6d50174 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Sat, 27 Aug 2022 09:24:40 -0300 Subject: [PATCH 11/34] Add separate environments for different tests --- .github/workflows/ci.yaml | 60 +++++++++++- .github/workflows/release.yaml | 64 ++++++++++++- .../new_relations/test_new_relations.py | 10 ++ tests/integration/test_charm.py | 6 ++ tests/integration/test_db.py | 2 + tests/integration/test_db_admin.py | 2 + tox.ini | 91 ++++++++++++++++++- 7 files changed, 226 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a759f2c515..ab8c30e558 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,6 +13,7 @@ jobs: run: python3 -m pip install tox - name: Run linters run: tox -e lint + unit-test: name: Unit tests runs-on: ubuntu-latest @@ -25,8 +26,61 @@ jobs: run: tox -e unit - name: Upload Coverage to Codecov uses: codecov/codecov-action@v2 - integration-test-lxd: - name: Integration tests (lxd) + + integration-test-lxd-charm: + name: Integration tests for charm deployment (lxd) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e charm-integration + + integration-test-lxd-database-relation: + name: Integration tests for database relation (lxd) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e database-relation-integration + + integration-test-lxd-db-relation: + name: Integration tests for db relation (lxd) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e db-relation-integration + + integration-test-lxd-db-admin-relation: + name: Integration tests for db-admin relation (lxd) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e db-admin-relation-integration + + integration-test-lxd-password-rotation: + name: Integration tests for password rotation (lxd) runs-on: ubuntu-latest steps: - name: Checkout @@ -36,4 +90,4 @@ jobs: with: provider: lxd - name: Run integration tests - run: tox -e integration + run: tox -e password-rotation-integration diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 80f700bba0..9c00b80780 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -42,8 +42,8 @@ jobs: - name: Run tests run: tox -e unit - integration-test-lxd: - name: Integration tests for (lxd) + integration-test-charm: + name: Integration tests for charm deployment runs-on: ubuntu-latest steps: - name: Checkout @@ -53,7 +53,59 @@ jobs: with: provider: lxd - name: Run integration tests - run: tox -e integration + run: tox -e charm-integration + + integration-test-database-relation: + name: Integration tests for database relation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e database-relation-integration + + integration-test-db-relation: + name: Integration tests for db relation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e db-relation-integration + + integration-test-db-admin-relation: + name: Integration tests for db-admin relation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e db-admin-relation-integration + + integration-test-password-rotation: + name: Integration tests for password rotation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e password-rotation-integration release-to-charmhub: name: Release to CharmHub @@ -61,7 +113,11 @@ jobs: - lib-check - lint - unit-test - - integration-test-lxd + - integration-test-charm + - integration-test-database-relation + - integration-test-db-relation + - integration-test-db-admin-relation + - integration-test-password-rotation runs-on: ubuntu-latest steps: - name: Checkout diff --git a/tests/integration/new_relations/test_new_relations.py b/tests/integration/new_relations/test_new_relations.py index dc473f90b4..5bdc1dc51b 100644 --- a/tests/integration/new_relations/test_new_relations.py +++ b/tests/integration/new_relations/test_new_relations.py @@ -31,6 +31,7 @@ @pytest.mark.abort_on_fail +@pytest.mark.database_relation async def test_deploy_charms(ops_test: OpsTest, application_charm, database_charm): """Deploy both charms (application and database) to use in the tests.""" # Deploy both charms (multiple units for each application to test that later they correctly @@ -65,6 +66,7 @@ async def test_deploy_charms(ops_test: OpsTest, application_charm, database_char await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", timeout=3000) +@pytest.mark.database_relation async def test_no_read_only_endpoint_in_standalone_cluster(ops_test: OpsTest): """Test that there is no read-only endpoint in a standalone cluster.""" async with ops_test.fast_forward(): @@ -91,6 +93,7 @@ async def test_no_read_only_endpoint_in_standalone_cluster(ops_test: OpsTest): ) +@pytest.mark.database_relation async def test_read_only_endpoint_in_scaled_up_cluster(ops_test: OpsTest): """Test that there is read-only endpoint in a scaled up cluster.""" async with ops_test.fast_forward(): @@ -109,6 +112,7 @@ async def test_read_only_endpoint_in_scaled_up_cluster(ops_test: OpsTest): @pytest.mark.abort_on_fail +@pytest.mark.database_relation async def test_database_relation_with_charm_libraries(ops_test: OpsTest): """Test basic functionality of database relation interface.""" # Get the connection string to connect to the database using the read/write endpoint. @@ -156,6 +160,7 @@ async def test_database_relation_with_charm_libraries(ops_test: OpsTest): cursor.execute("DROP TABLE test;") +@pytest.mark.database_relation async def test_user_with_extra_roles(ops_test: OpsTest): """Test superuser actions and the request for more permissions.""" # Get the connection string to connect to the database. @@ -176,6 +181,7 @@ async def test_user_with_extra_roles(ops_test: OpsTest): connection.close() +@pytest.mark.database_relation async def test_two_applications_doesnt_share_the_same_relation_data( ops_test: OpsTest, application_charm ): @@ -210,6 +216,7 @@ async def test_two_applications_doesnt_share_the_same_relation_data( assert application_connection_string != another_application_connection_string +@pytest.mark.database_relation async def test_an_application_can_connect_to_multiple_database_clusters( ops_test: OpsTest, database_charm ): @@ -242,6 +249,7 @@ async def test_an_application_can_connect_to_multiple_database_clusters( assert application_connection_string != another_application_connection_string +@pytest.mark.database_relation async def test_an_application_can_connect_to_multiple_aliased_database_clusters( ops_test: OpsTest, database_charm ): @@ -277,6 +285,7 @@ async def test_an_application_can_connect_to_multiple_aliased_database_clusters( assert application_connection_string != another_application_connection_string +@pytest.mark.database_relation async def test_an_application_can_request_multiple_databases(ops_test: OpsTest, application_charm): """Test that an application can request additional databases using the same interface.""" # Relate the charms using another relation and wait for them exchanging some connection data. @@ -297,6 +306,7 @@ async def test_an_application_can_request_multiple_databases(ops_test: OpsTest, assert first_database_connection_string != second_database_connection_string +@pytest.mark.database_relation async def test_relation_data_is_updated_correctly_when_scaling(ops_test: OpsTest): """Test that relation data, like connection data, is updated correctly when scaling.""" # Retrieve the list of current database unit names. diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 64e828982d..a01c749eca 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -32,7 +32,9 @@ UNIT_IDS = [0, 1, 2] +@pytest.mark.skip_if_deployed @pytest.mark.abort_on_fail +@pytest.mark.charm @pytest.mark.parametrize("series", SERIES) async def test_deploy(ops_test: OpsTest, charm: str, series: str): """Deploy the charm-under-test. @@ -58,6 +60,7 @@ async def test_deploy(ops_test: OpsTest, charm: str, series: str): @pytest.mark.abort_on_fail +@pytest.mark.charm @pytest.mark.parametrize("series", SERIES) @pytest.mark.parametrize("unit_id", UNIT_IDS) async def test_database_is_up(ops_test: OpsTest, series: str, unit_id: int): @@ -71,6 +74,7 @@ async def test_database_is_up(ops_test: OpsTest, series: str, unit_id: int): assert result.status_code == 200 +@pytest.mark.charm @pytest.mark.parametrize("series", SERIES) @pytest.mark.parametrize("unit_id", UNIT_IDS) async def test_settings_are_correct(ops_test: OpsTest, series: str, unit_id: int): @@ -123,6 +127,7 @@ async def test_settings_are_correct(ops_test: OpsTest, series: str, unit_id: int assert settings["maximum_lag_on_failover"] == 1048576 +@pytest.mark.charm @pytest.mark.parametrize("series", SERIES) async def test_scale_down_and_up(ops_test: OpsTest, series: str): """Test data is replicated to new units after a scale up.""" @@ -208,6 +213,7 @@ async def test_scale_down_and_up(ops_test: OpsTest, series: str): await scale_application(ops_test, application_name, initial_scale) +@pytest.mark.charm @pytest.mark.parametrize("series", SERIES) async def test_persist_data_through_primary_deletion(ops_test: OpsTest, series: str): """Test data persists through a primary deletion.""" diff --git a/tests/integration/test_db.py b/tests/integration/test_db.py index 456f33b35e..f92d82ad2a 100644 --- a/tests/integration/test_db.py +++ b/tests/integration/test_db.py @@ -24,6 +24,7 @@ RELATION_NAME = "db" +@pytest.mark.db_relation async def test_mailman3_core_db(ops_test: OpsTest, charm: str) -> None: """Deploy Mailman3 Core to test the 'db' relation.""" async with ops_test.fast_forward(): @@ -92,6 +93,7 @@ async def test_mailman3_core_db(ops_test: OpsTest, charm: str) -> None: assert domain_name not in [domain.mail_host for domain in client.domains] +@pytest.mark.db_relation async def test_relation_data_is_updated_correctly_when_scaling(ops_test: OpsTest): """Test that relation data, like connection data, is updated correctly when scaling.""" # Retrieve the list of current database unit names. diff --git a/tests/integration/test_db_admin.py b/tests/integration/test_db_admin.py index 51c5e8ac50..1964537424 100644 --- a/tests/integration/test_db_admin.py +++ b/tests/integration/test_db_admin.py @@ -5,6 +5,7 @@ import json import logging +import pytest as pytest from landscape_api.base import run_query from pytest_operator.plugin import OpsTest @@ -24,6 +25,7 @@ DATABASE_UNITS = 3 +@pytest.mark.db_admin_relation async def test_landscape_scalable_bundle_db(ops_test: OpsTest, charm: str) -> None: """Deploy Landscape Scalable Bundle to test the 'db-admin' relation.""" config = { diff --git a/tox.ini b/tox.ini index 5d07a7aa2e..0a52161e5f 100644 --- a/tox.ini +++ b/tox.ini @@ -65,8 +65,96 @@ commands = coverage report coverage xml +[testenv:charm-integration] +description = Run charm integration tests +deps = + pytest + juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + pytest-operator + psycopg2-binary + -r{toxinidir}/requirements.txt +commands = + # Download patroni resource to use in the charm deployment. + sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m charm + # Remove the downloaded resource. + sh -c 'rm -f patroni.tar.gz' +whitelist_externals = + sh + +[testenv:database-relation-integration] +description = Run database relation integration tests +deps = + pytest + juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + pytest-operator + psycopg2-binary + -r{toxinidir}/requirements.txt +commands = + # Download patroni resource to use in the charm deployment. + sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m database_relation + # Remove the downloaded resource. + sh -c 'rm -f patroni.tar.gz' +whitelist_externals = + sh + +[testenv:db-relation-integration] +description = Run db relation integration tests +deps = + pytest + juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + mailmanclient + pytest-operator + psycopg2-binary + -r{toxinidir}/requirements.txt +commands = + # Download patroni resource to use in the charm deployment. + sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs}-m db_relation + # Remove the downloaded resource. + sh -c 'rm -f patroni.tar.gz' +whitelist_externals = + sh + +[testenv:db-admin-relation-integration] +description = Run db-admin relation integration tests +deps = + pytest + juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + landscape-api-py3 + pytest-operator + psycopg2-binary + -r{toxinidir}/requirements.txt +commands = + # Download patroni resource to use in the charm deployment. + sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m db_admin_relation + # Remove the downloaded resource. + sh -c 'rm -f patroni.tar.gz' +whitelist_externals = + sh + +[testenv:password-rotation-integration] +description = Run password rotation integration tests +deps = + pytest + juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + landscape-api-py3 + pytest-operator + psycopg2-binary + -r{toxinidir}/requirements.txt +commands = + # Download patroni resource to use in the charm deployment. + sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m password_rotation + # Remove the downloaded resource. + sh -c 'rm -f patroni.tar.gz' +whitelist_externals = + sh + [testenv:integration] -description = Run integration tests +description = Run all integration tests deps = pytest juju==2.9.11 # juju 3.0.0 has issues with retrieving action results @@ -74,7 +162,6 @@ deps = mailmanclient pytest-operator psycopg2-binary - requests -r{toxinidir}/requirements.txt commands = # Download patroni resource to use in the charm deployment. From bc723d1b3b27b1f403a83c2d7252224e5578184d Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Sat, 27 Aug 2022 09:34:16 -0300 Subject: [PATCH 12/34] Add all dependencies --- tox.ini | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 0a52161e5f..6c123cda2a 100644 --- a/tox.ini +++ b/tox.ini @@ -70,6 +70,8 @@ description = Run charm integration tests deps = pytest juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + landscape-api-py3 + mailmanclient pytest-operator psycopg2-binary -r{toxinidir}/requirements.txt @@ -87,6 +89,8 @@ description = Run database relation integration tests deps = pytest juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + landscape-api-py3 + mailmanclient pytest-operator psycopg2-binary -r{toxinidir}/requirements.txt @@ -104,6 +108,7 @@ description = Run db relation integration tests deps = pytest juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + landscape-api-py3 mailmanclient pytest-operator psycopg2-binary @@ -111,7 +116,7 @@ deps = commands = # Download patroni resource to use in the charm deployment. sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs}-m db_relation + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m db_relation # Remove the downloaded resource. sh -c 'rm -f patroni.tar.gz' whitelist_externals = @@ -123,6 +128,7 @@ deps = pytest juju==2.9.11 # juju 3.0.0 has issues with retrieving action results landscape-api-py3 + mailmanclient pytest-operator psycopg2-binary -r{toxinidir}/requirements.txt @@ -141,6 +147,7 @@ deps = pytest juju==2.9.11 # juju 3.0.0 has issues with retrieving action results landscape-api-py3 + mailmanclient pytest-operator psycopg2-binary -r{toxinidir}/requirements.txt From 2d90e0339b20a8e67828dfc77a58cbc0eda125ab Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Sat, 27 Aug 2022 10:14:57 -0300 Subject: [PATCH 13/34] Add pytest marks --- tests/integration/test_password_rotation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_password_rotation.py b/tests/integration/test_password_rotation.py index 2f4e8ae4b5..f0811e10a1 100644 --- a/tests/integration/test_password_rotation.py +++ b/tests/integration/test_password_rotation.py @@ -17,8 +17,9 @@ APP_NAME = METADATA["name"] -@pytest.mark.skip_if_deployed @pytest.mark.abort_on_fail +@pytest.mark.password_rotation +@pytest.mark.skip_if_deployed async def test_deploy_active(ops_test: OpsTest): """Build the charm and deploy it.""" charm = await ops_test.build_charm(".") @@ -30,6 +31,7 @@ async def test_deploy_active(ops_test: OpsTest): await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) +@pytest.mark.password_rotation async def test_password_rotation(ops_test: OpsTest): """Test password rotation action.""" # Get the initial passwords set for the system users. From 3f259d5903639f228ea0f7c7b1333ea666993aec Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 2 Sep 2022 08:38:14 -0300 Subject: [PATCH 14/34] Fix action description --- actions.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions.yaml b/actions.yaml index 9a0c23a845..54a0da8f89 100644 --- a/actions.yaml +++ b/actions.yaml @@ -4,7 +4,7 @@ get-primary: description: Get the unit which is the primary/leader in the replication. get-password: - description: Change the system user's password, which is used by charm. + description: Get the system user's password, which is used by charm. It is for internal charm users and SHOULD NOT be used by applications. params: username: From 56ff42a60fc49b5643fd6a4c42591956e69d057f Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 2 Sep 2022 08:40:51 -0300 Subject: [PATCH 15/34] Fix method docstring --- lib/charms/postgresql_k8s/v0/postgresql.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/charms/postgresql_k8s/v0/postgresql.py b/lib/charms/postgresql_k8s/v0/postgresql.py index 6e87561a5e..10b65132c6 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql.py +++ b/lib/charms/postgresql_k8s/v0/postgresql.py @@ -203,9 +203,11 @@ def list_users(self) -> Set[str]: def update_user_password(self, username: str, password: str) -> None: """Update a user password. + Args: username: the user to update the password. password: the new password for the user. + Raises: PostgreSQLUpdateUserPasswordError if the password couldn't be changed. """ From 62cc60470877cdc751d0bcb0b7c6d21f4bf9d12c Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 2 Sep 2022 08:48:46 -0300 Subject: [PATCH 16/34] Fix pytest mark --- tests/integration/test_password_rotation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_password_rotation.py b/tests/integration/test_password_rotation.py index 5181e7f510..0dd04ef0da 100644 --- a/tests/integration/test_password_rotation.py +++ b/tests/integration/test_password_rotation.py @@ -18,7 +18,7 @@ @pytest.mark.abort_on_fail -@pytest.mark.password_rotation +@pytest.mark.password_rotation_tests @pytest.mark.skip_if_deployed async def test_deploy_active(ops_test: OpsTest): """Build the charm and deploy it.""" @@ -31,7 +31,7 @@ async def test_deploy_active(ops_test: OpsTest): await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) -@pytest.mark.password_rotation +@pytest.mark.password_rotation_tests async def test_password_rotation(ops_test: OpsTest): """Test password rotation action.""" # Get the initial passwords set for the system users. From 63004b41a0ad05185940657dde1961ef99d83d8f Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 2 Sep 2022 18:39:22 -0300 Subject: [PATCH 17/34] Fix pytest markers --- .../new_relations/test_new_relations.py | 21 +++++++++---------- tests/integration/test_charm.py | 12 +++++------ tests/integration/test_db.py | 4 ++-- tests/integration/test_db_admin.py | 2 +- tests/integration/test_password_rotation.py | 4 ++-- tox.ini | 10 ++++----- 6 files changed, 26 insertions(+), 27 deletions(-) diff --git a/tests/integration/new_relations/test_new_relations.py b/tests/integration/new_relations/test_new_relations.py index 5bdc1dc51b..93a9b090ff 100644 --- a/tests/integration/new_relations/test_new_relations.py +++ b/tests/integration/new_relations/test_new_relations.py @@ -31,7 +31,7 @@ @pytest.mark.abort_on_fail -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_deploy_charms(ops_test: OpsTest, application_charm, database_charm): """Deploy both charms (application and database) to use in the tests.""" # Deploy both charms (multiple units for each application to test that later they correctly @@ -66,7 +66,7 @@ async def test_deploy_charms(ops_test: OpsTest, application_charm, database_char await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", timeout=3000) -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_no_read_only_endpoint_in_standalone_cluster(ops_test: OpsTest): """Test that there is no read-only endpoint in a standalone cluster.""" async with ops_test.fast_forward(): @@ -93,7 +93,7 @@ async def test_no_read_only_endpoint_in_standalone_cluster(ops_test: OpsTest): ) -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_read_only_endpoint_in_scaled_up_cluster(ops_test: OpsTest): """Test that there is read-only endpoint in a scaled up cluster.""" async with ops_test.fast_forward(): @@ -111,8 +111,7 @@ async def test_read_only_endpoint_in_scaled_up_cluster(ops_test: OpsTest): ) -@pytest.mark.abort_on_fail -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_database_relation_with_charm_libraries(ops_test: OpsTest): """Test basic functionality of database relation interface.""" # Get the connection string to connect to the database using the read/write endpoint. @@ -160,7 +159,7 @@ async def test_database_relation_with_charm_libraries(ops_test: OpsTest): cursor.execute("DROP TABLE test;") -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_user_with_extra_roles(ops_test: OpsTest): """Test superuser actions and the request for more permissions.""" # Get the connection string to connect to the database. @@ -181,7 +180,7 @@ async def test_user_with_extra_roles(ops_test: OpsTest): connection.close() -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_two_applications_doesnt_share_the_same_relation_data( ops_test: OpsTest, application_charm ): @@ -216,7 +215,7 @@ async def test_two_applications_doesnt_share_the_same_relation_data( assert application_connection_string != another_application_connection_string -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_an_application_can_connect_to_multiple_database_clusters( ops_test: OpsTest, database_charm ): @@ -249,7 +248,7 @@ async def test_an_application_can_connect_to_multiple_database_clusters( assert application_connection_string != another_application_connection_string -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_an_application_can_connect_to_multiple_aliased_database_clusters( ops_test: OpsTest, database_charm ): @@ -285,7 +284,7 @@ async def test_an_application_can_connect_to_multiple_aliased_database_clusters( assert application_connection_string != another_application_connection_string -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_an_application_can_request_multiple_databases(ops_test: OpsTest, application_charm): """Test that an application can request additional databases using the same interface.""" # Relate the charms using another relation and wait for them exchanging some connection data. @@ -306,7 +305,7 @@ async def test_an_application_can_request_multiple_databases(ops_test: OpsTest, assert first_database_connection_string != second_database_connection_string -@pytest.mark.database_relation +@pytest.mark.database_relation_tests async def test_relation_data_is_updated_correctly_when_scaling(ops_test: OpsTest): """Test that relation data, like connection data, is updated correctly when scaling.""" # Retrieve the list of current database unit names. diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 9e9f067f0f..d8c6531bdc 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -32,10 +32,10 @@ UNIT_IDS = [0, 1, 2] -@pytest.mark.skip_if_deployed @pytest.mark.abort_on_fail -@pytest.mark.charm +@pytest.mark.charm_tests @pytest.mark.parametrize("series", SERIES) +@pytest.mark.skip_if_deployed async def test_deploy(ops_test: OpsTest, charm: str, series: str): """Deploy the charm-under-test. @@ -60,7 +60,7 @@ async def test_deploy(ops_test: OpsTest, charm: str, series: str): @pytest.mark.abort_on_fail -@pytest.mark.charm +@pytest.mark.charm_tests @pytest.mark.parametrize("series", SERIES) @pytest.mark.parametrize("unit_id", UNIT_IDS) async def test_database_is_up(ops_test: OpsTest, series: str, unit_id: int): @@ -74,7 +74,7 @@ async def test_database_is_up(ops_test: OpsTest, series: str, unit_id: int): assert result.status_code == 200 -@pytest.mark.charm +@pytest.mark.charm_tests @pytest.mark.parametrize("series", SERIES) @pytest.mark.parametrize("unit_id", UNIT_IDS) async def test_settings_are_correct(ops_test: OpsTest, series: str, unit_id: int): @@ -124,7 +124,7 @@ async def test_settings_are_correct(ops_test: OpsTest, series: str, unit_id: int assert settings["maximum_lag_on_failover"] == 1048576 -@pytest.mark.charm +@pytest.mark.charm_tests @pytest.mark.parametrize("series", SERIES) async def test_scale_down_and_up(ops_test: OpsTest, series: str): """Test data is replicated to new units after a scale up.""" @@ -210,7 +210,7 @@ async def test_scale_down_and_up(ops_test: OpsTest, series: str): await scale_application(ops_test, application_name, initial_scale) -@pytest.mark.charm +@pytest.mark.charm_tests @pytest.mark.parametrize("series", SERIES) async def test_persist_data_through_primary_deletion(ops_test: OpsTest, series: str): """Test data persists through a primary deletion.""" diff --git a/tests/integration/test_db.py b/tests/integration/test_db.py index f92d82ad2a..7212d53086 100644 --- a/tests/integration/test_db.py +++ b/tests/integration/test_db.py @@ -24,7 +24,7 @@ RELATION_NAME = "db" -@pytest.mark.db_relation +@pytest.mark.db_relation_tests async def test_mailman3_core_db(ops_test: OpsTest, charm: str) -> None: """Deploy Mailman3 Core to test the 'db' relation.""" async with ops_test.fast_forward(): @@ -93,7 +93,7 @@ async def test_mailman3_core_db(ops_test: OpsTest, charm: str) -> None: assert domain_name not in [domain.mail_host for domain in client.domains] -@pytest.mark.db_relation +@pytest.mark.db_relation_tests async def test_relation_data_is_updated_correctly_when_scaling(ops_test: OpsTest): """Test that relation data, like connection data, is updated correctly when scaling.""" # Retrieve the list of current database unit names. diff --git a/tests/integration/test_db_admin.py b/tests/integration/test_db_admin.py index 1964537424..d8b29fb2ca 100644 --- a/tests/integration/test_db_admin.py +++ b/tests/integration/test_db_admin.py @@ -25,7 +25,7 @@ DATABASE_UNITS = 3 -@pytest.mark.db_admin_relation +@pytest.mark.db_admin_relation_tests async def test_landscape_scalable_bundle_db(ops_test: OpsTest, charm: str) -> None: """Deploy Landscape Scalable Bundle to test the 'db-admin' relation.""" config = { diff --git a/tests/integration/test_password_rotation.py b/tests/integration/test_password_rotation.py index f0811e10a1..ef3e84555e 100644 --- a/tests/integration/test_password_rotation.py +++ b/tests/integration/test_password_rotation.py @@ -18,7 +18,7 @@ @pytest.mark.abort_on_fail -@pytest.mark.password_rotation +@pytest.mark.password_rotation_tests @pytest.mark.skip_if_deployed async def test_deploy_active(ops_test: OpsTest): """Build the charm and deploy it.""" @@ -31,7 +31,7 @@ async def test_deploy_active(ops_test: OpsTest): await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) -@pytest.mark.password_rotation +@pytest.mark.password_rotation_tests async def test_password_rotation(ops_test: OpsTest): """Test password rotation action.""" # Get the initial passwords set for the system users. diff --git a/tox.ini b/tox.ini index 6c123cda2a..c1d1bd1f97 100644 --- a/tox.ini +++ b/tox.ini @@ -78,7 +78,7 @@ deps = commands = # Download patroni resource to use in the charm deployment. sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m charm + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m charm_tests # Remove the downloaded resource. sh -c 'rm -f patroni.tar.gz' whitelist_externals = @@ -97,7 +97,7 @@ deps = commands = # Download patroni resource to use in the charm deployment. sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m database_relation + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m database_relation_tests # Remove the downloaded resource. sh -c 'rm -f patroni.tar.gz' whitelist_externals = @@ -116,7 +116,7 @@ deps = commands = # Download patroni resource to use in the charm deployment. sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m db_relation + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m db_relation_tests # Remove the downloaded resource. sh -c 'rm -f patroni.tar.gz' whitelist_externals = @@ -135,7 +135,7 @@ deps = commands = # Download patroni resource to use in the charm deployment. sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m db_admin_relation + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m db_admin_relation_tests # Remove the downloaded resource. sh -c 'rm -f patroni.tar.gz' whitelist_externals = @@ -154,7 +154,7 @@ deps = commands = # Download patroni resource to use in the charm deployment. sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m password_rotation + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m password_rotation_tests # Remove the downloaded resource. sh -c 'rm -f patroni.tar.gz' whitelist_externals = From 42a5b13116a59f2034d967b9d8d32fbc46be2113 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Sat, 3 Sep 2022 10:16:46 -0300 Subject: [PATCH 18/34] Register pytest markers --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f974d2ae79..d873e96cb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,13 @@ show_missing = true minversion = "6.0" log_cli_level = "INFO" asyncio_mode = "auto" +markers = [ + "charm_tests", + "database_relation_tests", + "db_relation_tests", + "db_admin_relation_tests", + "password_rotation_tests", +] # Formatting tools configuration [tool.black] From 3b042659e60d8da246a102ba568a7a2b47b0d4fc Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 6 Sep 2022 10:40:02 -0300 Subject: [PATCH 19/34] Import files --- charmcraft.yaml | 5 + .../postgresql_k8s/v0/postgresql_tls.py | 192 +++ lib/charms/rolling_ops/v0/rollingops.py | 390 ++++++ .../v1/tls_certificates.py | 1101 +++++++++++++++++ metadata.yaml | 7 + requirements.txt | 2 + 6 files changed, 1697 insertions(+) create mode 100644 lib/charms/postgresql_k8s/v0/postgresql_tls.py create mode 100644 lib/charms/rolling_ops/v0/rollingops.py create mode 100644 lib/charms/tls_certificates_interface/v1/tls_certificates.py diff --git a/charmcraft.yaml b/charmcraft.yaml index 3bbd6d53f2..2d17c43ed0 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -11,4 +11,9 @@ bases: channel: "20.04" parts: charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo charm-binary-python-packages: [psycopg2-binary==2.9.3] diff --git a/lib/charms/postgresql_k8s/v0/postgresql_tls.py b/lib/charms/postgresql_k8s/v0/postgresql_tls.py new file mode 100644 index 0000000000..5f1a0c2955 --- /dev/null +++ b/lib/charms/postgresql_k8s/v0/postgresql_tls.py @@ -0,0 +1,192 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +"""In this class we manage certificates relation. + +This class handles certificate request and renewal through +the interaction with the TLS Certificates Operator. + +This library needs that https://charmhub.io/tls-certificates-interface/libraries/tls_certificates +library is imported to work. + +It also needs the following methods in the charm class: +— get_hostname_by_unit: to retrieve the DNS hostname of the unit. +— get_secret: to retrieve TLS files from secrets. +— push_tls_files_to_workload: to push TLS files to the workload container and enable TLS. +— set_secret: to store TLS files as secrets. +— update_config: to disable TLS when relation with the TLS Certificates Operator is broken. +""" + +import base64 +import logging +import re +import socket +from typing import List, Optional + +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + TLSCertificatesRequiresV1, + generate_csr, + generate_private_key, +) +from cryptography import x509 +from cryptography.x509.extensions import ExtensionType +from ops.charm import ActionEvent +from ops.framework import Object +from ops.pebble import PathError, ProtocolError + +# The unique Charmhub library identifier, never change it +LIBID = "c27af44a92df4ef38d7ae06418b2800f" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version. +LIBPATCH = 1 + +logger = logging.getLogger(__name__) +SCOPE = "unit" +TLS_RELATION = "certificates" + + +class PostgreSQLTLS(Object): + """In this class we manage certificates relation.""" + + def __init__(self, charm, peer_relation): + """Manager of PostgreSQL relation with TLS Certificates Operator.""" + super().__init__(charm, "client-relations") + self.charm = charm + self.peer_relation = peer_relation + self.certs = TLSCertificatesRequiresV1(self.charm, TLS_RELATION) + self.framework.observe( + self.charm.on.set_tls_private_key_action, self._on_set_tls_private_key + ) + self.framework.observe( + self.charm.on[TLS_RELATION].relation_joined, self._on_tls_relation_joined + ) + self.framework.observe( + self.charm.on[TLS_RELATION].relation_broken, self._on_tls_relation_broken + ) + self.framework.observe(self.certs.on.certificate_available, self._on_certificate_available) + self.framework.observe(self.certs.on.certificate_expiring, self._on_certificate_expiring) + + def _on_set_tls_private_key(self, event: ActionEvent) -> None: + """Set the TLS private key, which will be used for requesting the certificate.""" + self._request_certificate(event.params.get("private-key", None)) + + def _request_certificate(self, param: Optional[str]): + """Request a certificate to TLS Certificates Operator.""" + if param is None: + key = generate_private_key() + else: + key = self._parse_tls_file(param) + + csr = generate_csr( + private_key=key, + subject=self.charm.get_hostname_by_unit(self.charm.unit.name), + sans=self._get_sans(), + additional_critical_extensions=self._get_tls_extensions(), + ) + + self.charm.set_secret(SCOPE, "key", key.decode("utf-8")) + self.charm.set_secret(SCOPE, "csr", csr.decode("utf-8")) + + if self.charm.model.get_relation(TLS_RELATION): + self.certs.request_certificate_creation(certificate_signing_request=csr) + + @staticmethod + def _parse_tls_file(raw_content: str) -> bytes: + """Parse TLS files from both plain text or base64 format.""" + plain_text_tls_file_regex = r"(-+(BEGIN|END) [A-Z ]+-+)" + if re.match(plain_text_tls_file_regex, raw_content): + return re.sub( + plain_text_tls_file_regex, + "\\1", + raw_content, + ).encode("utf-8") + return base64.b64decode(raw_content) + + def _on_tls_relation_joined(self, _) -> None: + """Request certificate when TLS relation joined.""" + self._request_certificate(None) + + def _on_tls_relation_broken(self, _) -> None: + """Disable TLS when TLS relation broken.""" + self.charm.set_secret(SCOPE, "ca", None) + self.charm.set_secret(SCOPE, "cert", None) + self.charm.set_secret(SCOPE, "chain", None) + self.charm.update_config() + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + """Enable TLS when TLS certificate available.""" + if event.certificate_signing_request != self.charm.get_secret(SCOPE, "csr"): + logger.error("An unknown certificate expiring.") + return + + self.charm.set_secret(SCOPE, "chain", event.chain) + self.charm.set_secret(SCOPE, "cert", event.certificate) + self.charm.set_secret(SCOPE, "ca", event.ca) + + try: + self.charm.push_tls_files_to_workload() + except (PathError, ProtocolError) as e: + logger.error("Cannot push TLS certificates: %r", e) + event.defer() + return + + def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: + """Request the new certificate when old certificate is expiring.""" + if event.certificate != self.charm.get_secret(SCOPE, "cert"): + logger.error("An unknown certificate expiring.") + return + + key = self.charm.get_secret(SCOPE, "key").encode("utf-8") + old_csr = self.charm.get_secret(SCOPE, "csr").encode("utf-8") + new_csr = generate_csr( + private_key=key, + subject=self.charm.get_hostname_by_unit(self.charm.unit.name), + sans=self._get_sans(), + additional_critical_extensions=self._get_tls_extensions(), + ) + self.certs.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + self.charm.set_secret(SCOPE, "csr", new_csr.decode("utf-8")) + + def _get_sans(self) -> List[str]: + """Create a list of DNS names for a PostgreSQL unit. + + Returns: + A list representing the hostnames of the PostgreSQL unit. + """ + unit_id = self.charm.unit.name.split("/")[1] + return [ + f"{self.charm.app.name}-{unit_id}", + socket.getfqdn(), + str(self.charm.model.get_binding(self.peer_relation).network.bind_address), + ] + + @staticmethod + def _get_tls_extensions() -> Optional[List[ExtensionType]]: + """Return a list of TLS extensions for which certificate key can be used.""" + basic_constraints = x509.BasicConstraints(ca=True, path_length=None) + return [basic_constraints] + + def get_tls_files(self) -> (Optional[str], Optional[str]): + """Prepare TLS files in special PostgreSQL way. + + PostgreSQL needs three files: + — CA file should have a full chain. + — Key file should have private key. + — Certificate file should have certificate without certificate chain. + """ + ca = self.charm.get_secret(SCOPE, "ca") + chain = self.charm.get_secret(SCOPE, "chain") + ca_file = chain if chain else ca + + key = self.charm.get_secret(SCOPE, "key") + cert = self.charm.get_secret(SCOPE, "cert") + return key, ca_file, cert diff --git a/lib/charms/rolling_ops/v0/rollingops.py b/lib/charms/rolling_ops/v0/rollingops.py new file mode 100644 index 0000000000..bca24b07fb --- /dev/null +++ b/lib/charms/rolling_ops/v0/rollingops.py @@ -0,0 +1,390 @@ +# Copyright 2022 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This library enables "rolling" operations across units of a charmed Application. + +For example, a charm author might use this library to implement a "rolling restart", in +which all units in an application restart their workload, but no two units execute the +restart at the same time. + +To implement the rolling restart, a charm author would do the following: + +1. Add a peer relation called 'restart' to a charm's `metadata.yaml`: +```yaml +peers: + restart: + interface: rolling_op +``` + +Import this library into src/charm.py, and initialize a RollingOpsManager in the Charm's +`__init__`. The Charm should also define a callback routine, which will be executed when +a unit holds the distributed lock: + +src/charm.py +```python +# ... +from charms.rolling_ops.v0.rollingops import RollingOpsManager +# ... +class SomeCharm(...): + def __init__(...) + # ... + self.restart_manager = RollingOpsManager( + charm=self, relation="restart", callback=self._restart + ) + # ... + def _restart(self, event): + systemd.service_restart('foo') +``` + +To kick off the rolling restart, emit this library's AcquireLock event. The simplest way +to do so would be with an action, though it might make sense to acquire the lock in +response to another event. + +```python + def _on_trigger_restart(self, event): + self.charm.on[self.restart_manager.name].acquire_lock.emit() +``` + +In order to trigger the restart, a human operator would execute the following command on +the CLI: + +``` +juju run-action some-charm/0 some-charm/1 <... some-charm/n> restart +``` + +Note that all units that plan to restart must receive the action and emit the aquire +event. Any units that do not run their acquire handler will be left out of the rolling +restart. (An operator might take advantage of this fact to recover from a failed rolling +operation without restarting workloads that were able to successfully restart -- simply +omit the successful units from a subsequent run-action call.) + +""" +import logging +from enum import Enum +from typing import AnyStr, Callable + +from ops.charm import ActionEvent, CharmBase, RelationChangedEvent +from ops.framework import EventBase, Object +from ops.model import ActiveStatus, MaintenanceStatus, WaitingStatus + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "20b7777f58fe421e9a223aefc2b4d3a4" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 2 + + +class LockNoRelationError(Exception): + """Raised if we are trying to process a lock, but do not appear to have a relation yet.""" + + pass + + +class LockState(Enum): + """Possible states for our Distributed lock. + + Note that there are two states set on the unit, and two on the application. + + """ + + ACQUIRE = "acquire" + RELEASE = "release" + GRANTED = "granted" + IDLE = "idle" + + +class Lock: + """A class that keeps track of a single asynchronous lock. + + Warning: a Lock has permission to update relation data, which means that there are + side effects to invoking the .acquire, .release and .grant methods. Running any one of + them will trigger a RelationChanged event, once per transition from one internal + status to another. + + This class tracks state across the cloud by implementing a peer relation + interface. There are two parts to the interface: + + 1) The data on a unit's peer relation (defined in metadata.yaml.) Each unit can update + this data. The only meaningful values are "acquire", and "release", which represent + a request to acquire the lock, and a request to release the lock, respectively. + + 2) The application data in the relation. This tracks whether the lock has been + "granted", Or has been released (and reverted to idle). There are two valid states: + "granted" or None. If a lock is in the "granted" state, a unit should emit a + RunWithLocks event and then release the lock. + + If a lock is in "None", this means that a unit has not yet requested the lock, or + that the request has been completed. + + In more detail, here is the relation structure: + + relation.data: + : + status: 'acquire|release' + : + : 'granted|None' + + Note that this class makes no attempts to timestamp the locks and thus handle multiple + requests in a row. If a unit re-requests a lock before being granted the lock, the + lock will simply stay in the "acquire" state. If a unit wishes to clear its lock, it + simply needs to call lock.release(). + + """ + + def __init__(self, manager, unit=None): + + self.relation = manager.model.relations[manager.name][0] + if not self.relation: + # TODO: defer caller in this case (probably just fired too soon). + raise LockNoRelationError() + + self.unit = unit or manager.model.unit + self.app = manager.model.app + + @property + def _state(self) -> LockState: + """Return an appropriate state. + + Note that the state exists in the unit's relation data, and the application + relation data, so we have to be careful about what our states mean. + + Unit state can only be in "acquire", "release", "None" (None means unset) + Application state can only be in "granted" or "None" (None means unset or released) + + """ + unit_state = LockState(self.relation.data[self.unit].get("state", LockState.IDLE.value)) + app_state = LockState( + self.relation.data[self.app].get(str(self.unit), LockState.IDLE.value) + ) + + if app_state == LockState.GRANTED and unit_state == LockState.RELEASE: + # Active release request. + return LockState.RELEASE + + if app_state == LockState.IDLE and unit_state == LockState.ACQUIRE: + # Active acquire request. + return LockState.ACQUIRE + + return app_state # Granted or unset/released + + @_state.setter + def _state(self, state: LockState): + """Set the given state. + + Since we update the relation data, this may fire off a RelationChanged event. + """ + if state == LockState.ACQUIRE: + self.relation.data[self.unit].update({"state": state.value}) + + if state == LockState.RELEASE: + self.relation.data[self.unit].update({"state": state.value}) + + if state == LockState.GRANTED: + self.relation.data[self.app].update({str(self.unit): state.value}) + + if state is LockState.IDLE: + self.relation.data[self.app].update({str(self.unit): state.value}) + + def acquire(self): + """Request that a lock be acquired.""" + self._state = LockState.ACQUIRE + + def release(self): + """Request that a lock be released.""" + self._state = LockState.RELEASE + + def clear(self): + """Unset a lock.""" + self._state = LockState.IDLE + + def grant(self): + """Grant a lock to a unit.""" + self._state = LockState.GRANTED + + def is_held(self): + """This unit holds the lock.""" + return self._state == LockState.GRANTED + + def release_requested(self): + """A unit has reported that they are finished with the lock.""" + return self._state == LockState.RELEASE + + def is_pending(self): + """Is this unit waiting for a lock?""" + return self._state == LockState.ACQUIRE + + +class Locks: + """Generator that returns a list of locks.""" + + def __init__(self, manager): + self.manager = manager + + # Gather all the units. + relation = manager.model.relations[manager.name][0] + units = [unit for unit in relation.units] + + # Plus our unit ... + units.append(manager.model.unit) + + self.units = units + + def __iter__(self): + """Yields a lock for each unit we can find on the relation.""" + for unit in self.units: + yield Lock(self.manager, unit=unit) + + +class RunWithLock(EventBase): + """Event to signal that this unit should run the callback.""" + + pass + + +class AcquireLock(EventBase): + """Signals that this unit wants to acquire a lock.""" + + pass + + +class ProcessLocks(EventBase): + """Used to tell the leader to process all locks.""" + + pass + + +class RollingOpsManager(Object): + """Emitters and handlers for rolling ops.""" + + def __init__(self, charm: CharmBase, relation: AnyStr, callback: Callable): + """Register our custom events. + + params: + charm: the charm we are attaching this to. + relation: an identifier, by convention based on the name of the relation in the + metadata.yaml, which identifies this instance of RollingOperatorsFactory, + distinct from other instances that may be hanlding other events. + callback: a closure to run when we have a lock. (It must take a CharmBase object and + EventBase object as args.) + """ + # "Inherit" from the charm's class. This gives us access to the framework as + # self.framework, as well as the self.model shortcut. + super().__init__(charm, None) + + self.name = relation + self._callback = callback + self.charm = charm # Maintain a reference to charm, so we can emit events. + + charm.on.define_event("{}_run_with_lock".format(self.name), RunWithLock) + charm.on.define_event("{}_acquire_lock".format(self.name), AcquireLock) + charm.on.define_event("{}_process_locks".format(self.name), ProcessLocks) + + # Watch those events (plus the built in relation event). + self.framework.observe(charm.on[self.name].relation_changed, self._on_relation_changed) + self.framework.observe(charm.on[self.name].acquire_lock, self._on_acquire_lock) + self.framework.observe(charm.on[self.name].run_with_lock, self._on_run_with_lock) + self.framework.observe(charm.on[self.name].process_locks, self._on_process_locks) + + def _callback(self: CharmBase, event: EventBase) -> None: + """Placeholder for the function that actually runs our event. + + Usually overridden in the init. + """ + raise NotImplementedError + + def _on_relation_changed(self: CharmBase, event: RelationChangedEvent): + """Process relation changed. + + First, determine whether this unit has been granted a lock. If so, emit a RunWithLock + event. + + Then, if we are the leader, fire off a process locks event. + + """ + lock = Lock(self) + + if lock.is_pending(): + self.model.unit.status = WaitingStatus("Awaiting {} operation".format(self.name)) + + if lock.is_held(): + self.charm.on[self.name].run_with_lock.emit() + + if self.model.unit.is_leader(): + self.charm.on[self.name].process_locks.emit() + + def _on_process_locks(self: CharmBase, event: ProcessLocks): + """Process locks. + + Runs only on the leader. Updates the status of all locks. + + """ + if not self.model.unit.is_leader(): + return + + pending = [] + + for lock in Locks(self): + if lock.is_held(): + # One of our units has the lock -- return without further processing. + return + + if lock.release_requested(): + lock.clear() # Updates relation data + + if lock.is_pending(): + if lock.unit == self.model.unit: + # Always run on the leader last. + pending.insert(0, lock) + else: + pending.append(lock) + + # If we reach this point, and we have pending units, we want to grant a lock to + # one of them. + if pending: + self.model.app.status = MaintenanceStatus("Beginning rolling {}".format(self.name)) + lock = pending[-1] + lock.grant() + if lock.unit == self.model.unit: + # It's time for the leader to run with lock. + self.charm.on[self.name].run_with_lock.emit() + return + + self.model.app.status = ActiveStatus() + + def _on_acquire_lock(self: CharmBase, event: ActionEvent): + """Request a lock.""" + try: + Lock(self).acquire() # Updates relation data + # emit relation changed event in the edge case where aquire does not + relation = self.model.get_relation(self.name) + self.charm.on[self.name].relation_changed.emit(relation) + except LockNoRelationError: + logger.debug("No {} peer relation yet. Delaying rolling op.".format(self.name)) + event.defer() + + def _on_run_with_lock(self: CharmBase, event: RunWithLock): + lock = Lock(self) + self.model.unit.status = MaintenanceStatus("Executing {} operation".format(self.name)) + self._callback(event) + lock.release() # Updates relation data + if lock.unit == self.model.unit: + self.charm.on[self.name].process_locks.emit() + + self.model.unit.status = ActiveStatus() diff --git a/lib/charms/tls_certificates_interface/v1/tls_certificates.py b/lib/charms/tls_certificates_interface/v1/tls_certificates.py new file mode 100644 index 0000000000..3d69fff236 --- /dev/null +++ b/lib/charms/tls_certificates_interface/v1/tls_certificates.py @@ -0,0 +1,1101 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the tls-certificates relation. + +This library contains the Requires and Provides classes for handling the tls-certificates +interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.tls_certificates_interface.v1.tls_certificates +``` + +Add the following libraries to the charm's `requirements.txt` file: +- jsonschema +- cryptography + +Add the following section to the charm's `charmcraft.yaml` file: +```yaml +parts: + charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo +``` + +### Provider charm +The provider charm is the charm providing certificates to another charm that requires them. In +this example, the provider charm is storing its private key using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateCreationRequestEvent, + CertificateRevocationRequestEvent, + TLSCertificatesProvidesV1, + generate_private_key, +) +from ops.charm import CharmBase, InstallEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +def generate_ca(private_key: bytes, subject: str) -> str: + return "whatever ca content" + + +def generate_certificate(ca: str, private_key: str, csr: str) -> str: + return "Whatever certificate" + + +class ExampleProviderCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.certificates = TLSCertificatesProvidesV1(self, "certificates") + self.framework.observe( + self.certificates.on.certificate_request, self._on_certificate_request + ) + self.framework.observe( + self.certificates.on.certificate_revoked, self._on_certificate_revoked + ) + self.framework.observe(self.on.install, self._on_install) + + def _on_install(self, event: InstallEvent) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + ca_certificate = generate_ca(private_key=private_key, subject="whatever") + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + { + "private_key_password": "banana", + "private_key": private_key, + "ca_certificate": ca_certificate, + } + ) + self.unit.status = ActiveStatus() + + def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + ca_certificate = replicas_relation.data[self.app].get("ca_certificate") + private_key = replicas_relation.data[self.app].get("private_key") + certificate = generate_certificate( + ca=ca_certificate, + private_key=private_key, + csr=event.certificate_signing_request, + ) + + self.certificates.set_relation_certificate( + certificate=certificate, + certificate_signing_request=event.certificate_signing_request, + ca=ca_certificate, + chain=ca_certificate, + relation_id=event.relation_id, + ) + + def _on_certificate_revoked(self, event: CertificateRevocationRequestEvent) -> None: + # Do what you want to do with this information + pass + + +if __name__ == "__main__": + main(ExampleProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. In +this example, the requirer charm is storing its certificates using a peer relation interface called +`replicas`. + +Example: +```python + +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + CertificateRevokedEvent, + TLSCertificatesRequiresV1, + generate_csr, + generate_private_key, +) +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +class ExampleRequirerCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.cert_subject = "whatever" + self.certificates = TLSCertificatesRequiresV1(self, "certificates") + self.framework.observe(self.on.install, self._on_install) + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + self.framework.observe( + self.certificates.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.on.certificates.on.certificate_expiring, self._on_certificate_expiring + ) + self.framework.observe( + self.on.certificates.on.certificate_revoked, self._on_certificate_revoked + ) + + def _on_install(self, event) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + {"private_key_password": "banana", "private_key": private_key.decode()} + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + replicas_relation.data[self.app].update({"csr": csr.decode()}) + self.certificates.request_certificate_creation(certificate_signing_request=csr) + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update({"certificate": event.certificate}) + replicas_relation.data[self.app].update({"ca": event.ca}) + replicas_relation.data[self.app].update({"chain": event.chain}) + self.unit.status = ActiveStatus() + + def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + old_csr = replicas_relation.data[self.app].get("csr") + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + + def _on_certificate_revoked(self, event: CertificateRevokedEvent): + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + stored_csr = replicas_relation.data[self.app].get("csr") + if event.certificate_signing_request == stored_csr: + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=stored_csr, new_certificate_signing_request=new_csr + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + + +if __name__ == "__main__": + main(ExampleRequirerCharm) +``` +""" # noqa: D405, D410, D411, D214, D416 + +import json +import logging +import uuid +from datetime import datetime, timedelta +from typing import List, Optional + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from jsonschema import exceptions, validate # type: ignore[import] +from ops.charm import CharmBase, CharmEvents, RelationChangedEvent, UpdateStatusEvent +from ops.framework import EventBase, EventSource, Handle, Object + +# The unique Charmhub library identifier, never change it +LIBID = "afd8c2bccf834997afce12c2706d2ede" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 2 + +REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/requirer.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` requirer root schema", + "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 + "examples": [ + { + "certificate_signing_requests": [ + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + ] + } + ], + "properties": { + "certificate_signing_requests": { + "type": "array", + "items": { + "type": "object", + "properties": {"certificate_signing_request": {"type": "string"}}, + "required": ["certificate_signing_request"], + }, + } + }, + "required": ["certificate_signing_requests"], + "additionalProperties": True, +} + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/provider.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` provider root schema", + "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 + "example": [ + { + "certificates": [ + { + "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 + "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 + } + ] + } + ], + "properties": { + "certificates": { + "$id": "#/properties/certificates", + "type": "array", + "items": { + "$id": "#/properties/certificates/items", + "type": "object", + "required": ["certificate_signing_request", "certificate", "ca", "chain"], + "properties": { + "certificate_signing_request": { + "$id": "#/properties/certificates/items/certificate_signing_request", + "type": "string", + }, + "certificate": { + "$id": "#/properties/certificates/items/certificate", + "type": "string", + }, + "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, + "chain": {"$id": "#/properties/certificates/items/chain", "type": "string"}, + }, + }, + } + }, + "required": ["certificates"], +} + + +logger = logging.getLogger(__name__) + + +class CertificateAvailableEvent(EventBase): + """Charm Event triggered when a TLS certificate is available.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class CertificateExpiringEvent(EventBase): + """Charm Event triggered when a TLS certificate is almost expired.""" + + def __init__(self, handle, certificate: str, expiry: datetime): + """CertificateExpiringEvent. + + Args: + handle (Handle): Juju framework handle + certificate (str): TLS Certificate + expiry (datetime): Datetime object reprensenting the time at which the certificate + won't be valid anymore. + """ + super().__init__(handle) + self.certificate = certificate + self.expiry = expiry + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate, "expiry": self.expiry} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.expiry = snapshot["expiry"] + + +class CertificateExpiredEvent(EventBase): + """Charm Event triggered when a TLS certificate is expired.""" + + def __init__(self, handle: Handle, certificate: str): + super().__init__(handle) + self.certificate = certificate + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + + +class CertificateCreationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate is required.""" + + def __init__(self, handle: Handle, certificate_signing_request: str, relation_id: int): + super().__init__(handle) + self.certificate_signing_request = certificate_signing_request + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate_signing_request": self.certificate_signing_request, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.relation_id = snapshot["relation_id"] + + +class CertificateRevocationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate needs to be revoked.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class CertificateRevokedEvent(EventBase): + """Charm Event triggered when a TLS certificate is revoked.""" + + def __init__(self, handle, certificate_signing_request: str): + super().__init__(handle) + self.certificate_signing_request = certificate_signing_request + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate_signing_request": self.certificate_signing_request} + + def restore(self, snapshot): + """Restores snapshot.""" + self.certificate_signing_request = snapshot["certificate_signing_request"] + + +def _load_unit_relation_data(raw_relation_data: dict) -> dict: + """Loads relation data from the relation data bag. + + Json loads all data. + + Args: + raw_relation_data: Relation data from the databag + + Returns: + dict: Relation data in dict format. + """ + certificate_data = dict() + for key in raw_relation_data: + try: + certificate_data[key] = json.loads(raw_relation_data[key]) + except json.decoder.JSONDecodeError: + certificate_data[key] = raw_relation_data[key] + return certificate_data + + +def _get_provider_csrs(raw_provider_unit_relation_data: dict) -> List[str]: + provider_relation_data = _load_unit_relation_data(raw_provider_unit_relation_data) + return [ + certificate["certificate_signing_request"] + for certificate in provider_relation_data.get("certificates", []) + if certificate.get("certificate_signing_request", None) + ] + + +def _get_requirer_csrs(raw_requirer_unit_relation_data: dict) -> List[str]: + requirer_relation_data = _load_unit_relation_data(raw_requirer_unit_relation_data) + return [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in requirer_relation_data.get( + "certificate_signing_requests", [] + ) + if certificate_creation_request.get("certificate_signing_request", None) + ] + + +def generate_ca( + private_key: bytes, + subject: str, + private_key_password: Optional[bytes] = None, + validity: int = 365, + country: str = "US", +) -> bytes: + """Generates a CA Certificate. + + Args: + private_key (bytes): Private key + subject (str): Certificate subject + private_key_password (bytes): Private key password + validity (int): Certificate validity time (in days) + country (str): Certificate Issuing country + + Returns: + bytes: CA Certificate. + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), + x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), + ] + ) + subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( + private_key_object.public_key() # type: ignore[arg-type] + ) + subject_identifier = key_identifier = subject_identifier_object.public_bytes() + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key_object.public_key()) # type: ignore[arg-type] + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) + .add_extension( + x509.AuthorityKeyIdentifier( + key_identifier=key_identifier, + authority_cert_issuer=None, + authority_cert_serial_number=None, + ), + critical=False, + ) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] + ) + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_certificate( + csr: bytes, + ca: bytes, + ca_key: bytes, + ca_key_password: Optional[bytes] = None, + validity: int = 365, + alt_names: list = None, +) -> bytes: + """Generates a TLS certificate based on a CSR. + + Args: + csr (bytes): CSR + ca (bytes): CA Certificate + ca_key (bytes): CA private key + ca_key_password: CA private key password + validity (int): Certificate validity (in days) + alt_names: Certificate Subject alternative names + + Returns: + bytes: Certificate + """ + csr_object = x509.load_pem_x509_csr(csr) + subject = csr_object.subject + issuer = x509.load_pem_x509_certificate(ca).issuer + private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) + + certificate_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(csr_object.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + ) + if alt_names: + names = [x509.DNSName(n) for n in alt_names] + certificate_builder = certificate_builder.add_extension( + x509.SubjectAlternativeName(names), + critical=False, + ) + certificate_builder._version = x509.Version.v1 + cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_pfx_package( + certificate: bytes, + private_key: bytes, + package_password: str, + private_key_password: Optional[bytes] = None, +) -> bytes: + """Generates a PFX package to contain the TLS certificate and private key. + + Args: + certificate (bytes): TLS certificate + private_key (bytes): Private key + package_password (str): Password to open the PFX package + private_key_password (bytes): Private key password + + Returns: + bytes: + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + certificate_object = x509.load_pem_x509_certificate(certificate) + name = certificate_object.subject.rfc4514_string() + pfx_bytes = pkcs12.serialize_key_and_certificates( + name=name.encode(), + cert=certificate_object, + key=private_key_object, # type: ignore[arg-type] + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), + ) + return pfx_bytes + + +def generate_private_key( + password: Optional[bytes] = None, + key_size: int = 2048, + public_exponent: int = 65537, +) -> bytes: + """Generates a private key. + + Args: + password (bytes): Password for decrypting the private key + key_size (int): Key size in bytes + public_exponent: Public exponent. + + Returns: + bytes: Private Key + """ + private_key = rsa.generate_private_key( + public_exponent=public_exponent, + key_size=key_size, + ) + key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption(password) + if password + else serialization.NoEncryption(), + ) + return key_bytes + + +def generate_csr( + private_key: bytes, + subject: str, + add_unique_id_to_subject_name: bool = True, + organization: str = None, + email_address: str = None, + country_name: str = None, + private_key_password: Optional[bytes] = None, + sans: Optional[List[str]] = None, + additional_critical_extensions: Optional[List] = None, +) -> bytes: + """Generates a CSR using private key and subject. + + Args: + private_key (bytes): Private key + subject (str): CSR Subject. + add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's + subject name. Always leave to "True" when the CSR is used to request certificates + using the tls-certificates relation. + organization (str): Name of organization. + email_address (str): Email address. + country_name (str): Country Name. + private_key_password (bytes): Private key password + sans (list): List of subject alternative names + additional_critical_extensions (list): List if critical additional extension objects. + Object must be a x509 ExtensionType. + + Returns: + bytes: CSR + """ + signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) + subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] + if add_unique_id_to_subject_name: + unique_identifier = uuid.uuid4() + subject_name.append( + x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) + ) + if organization: + subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) + if email_address: + subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) + if country_name: + subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) + csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) + if sans: + csr = csr.add_extension( + x509.SubjectAlternativeName([x509.DNSName(san) for san in sans]), critical=False + ) + if additional_critical_extensions: + for extension in additional_critical_extensions: + csr = csr.add_extension(extension, critical=True) + signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] + return signed_certificate.public_bytes(serialization.Encoding.PEM) + + +class CertificatesProviderCharmEvents(CharmEvents): + """List of events that the TLS Certificates provider charm can leverage.""" + + certificate_creation_request = EventSource(CertificateCreationRequestEvent) + certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) + + +class CertificatesRequirerCharmEvents(CharmEvents): + """List of events that the TLS Certificates requirer charm can leverage.""" + + certificate_available = EventSource(CertificateAvailableEvent) + certificate_expiring = EventSource(CertificateExpiringEvent) + certificate_expired = EventSource(CertificateExpiredEvent) + certificate_revoked = EventSource(CertificateRevokedEvent) + + +class TLSCertificatesProvidesV1(Object): + """TLS certificates provider class to be instantiated by TLS certificates providers.""" + + on = CertificatesProviderCharmEvents() + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.charm = charm + self.relationship_name = relationship_name + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Uses JSON schema validator to validate relation data content. + + Args: + certificates_data (dict): Certificate data dictionary as retrieved from relation data. + + Returns: + bool: True/False depending on whether the relation data follows the json schema. + """ + try: + validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def set_relation_certificate( + self, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + relation_id: int, + ) -> None: + """Adds certificates to relation data. + + Args: + certificate (str): Certificate + certificate_signing_request (str): Certificate signing request + ca (str): CA Certificate + chain (str): CA Chain certificate + relation_id (int): Juju relation ID + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + provider_relation_data = certificates_relation.data[self.model.unit] # type: ignore[union-attr] # noqa: E501 + loaded_relation_data = _load_unit_relation_data(provider_relation_data) + new_certificate = { + "certificate": certificate.strip(), + "certificate_signing_request": certificate_signing_request.strip(), + "ca": ca.strip(), + "chain": chain.strip(), + } + if "certificates" not in loaded_relation_data: + certificates = [new_certificate] + else: + certificates = loaded_relation_data["certificates"] + for cert_dict in certificates: + if cert_dict["certificate_signing_request"] == certificate_signing_request.strip(): + certificates.remove(cert_dict) + loaded_relation_data["certificates"].append(new_certificate) + provider_relation_data["certificates"] = json.dumps(certificates) + + def remove_certificate(self, certificate: str) -> None: + """Removes a given certificate from relation data. + + Args: + certificate (str): TLS Certificate + + Returns: + None + """ + relations = self.model.relations + for certificate_relation in relations[self.relationship_name]: + provider_relation_data = certificate_relation.data[self.model.unit] + loaded_relation_data = _load_unit_relation_data(provider_relation_data) + provided_certificates = loaded_relation_data["certificates"] + for provided_certificate in provided_certificates: + if provided_certificate["certificate"] == certificate.strip(): + provided_certificates.remove(provided_certificate) + provider_relation_data["certificates"] = json.dumps(provided_certificates) + + def _on_relation_changed(self, event) -> None: + """Handler triggerred on relation changed event. + + Looks at the relation data and either emits: + - certificate request event: If the unit relation data contains a CSR for which + a certificate does not exist in the provider relation data. + - certificate revocation event: If the provider relation data contains a CSR for which + a csr does not exist in the requirer relation data. + + Args: + event: Juju event + + Returns: + None + """ + requirer_relation_data = _load_unit_relation_data(event.relation.data[event.unit]) + provider_relation_data = _load_unit_relation_data(event.relation.data[self.model.unit]) + if not self._relation_data_is_valid(requirer_relation_data): + logger.warning( + f"Relation data did not pass JSON Schema validation: {requirer_relation_data}" + ) + return + provider_csrs = _get_provider_csrs(event.relation.data[self.model.unit]) + requirer_csrs = _get_requirer_csrs(event.relation.data[event.unit]) + for certificate_signing_request in requirer_csrs: + if certificate_signing_request not in provider_csrs: + self.on.certificate_creation_request.emit( + certificate_signing_request=certificate_signing_request, + relation_id=event.relation.id, + ) + for certificate in provider_relation_data.get("certificates", []): + if certificate["certificate_signing_request"] not in requirer_csrs: + self.on.certificate_revocation_request.emit( + certificate=certificate["certificate"], + certificate_signing_request=certificate["certificate_signing_request"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + self.remove_certificate(certificate=certificate["certificate"]) + + +class TLSCertificatesRequiresV1(Object): + """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" + + on = CertificatesRequirerCharmEvents() + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + expiry_notification_time: int = 168, + ): + """Generates/use private key and observes relation changed event. + + Args: + charm: Charm object + relationship_name: Juju relation name + expiry_notification_time (int): Time difference between now and expiry (in hours). + Used to trigger the CertificateExpiring event. Default: 7 days. + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.expiry_notification_time = expiry_notification_time + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe(charm.on.update_status, self._on_update_status) + + def request_certificate_creation(self, certificate_signing_request: bytes) -> None: + """Request TLS certificate to provider charm. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + message = ( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + logger.error(message) + raise RuntimeError(message) + relation_data = _load_unit_relation_data(relation.data[self.model.unit]) + new_certificate_creation_request = { + "certificate_signing_request": certificate_signing_request.decode().strip() + } + if "certificate_signing_requests" in relation_data: + certificate_creation_request_list = relation_data["certificate_signing_requests"] + if new_certificate_creation_request in certificate_creation_request_list: + logger.info("Request was already made - Doing nothing") + return + certificate_creation_request_list.append(new_certificate_creation_request) + else: + certificate_creation_request_list = [new_certificate_creation_request] + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps( + certificate_creation_request_list + ) + logger.info("Certificate request sent to provider") + + def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: + """Removes CSR from relation data. + + The provider of this relation is then expected to remove certificates associated to this + CSR from the relation data as well and emit a request_certificate_revocation event for the + provider charm to interpret. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + relation_data = _load_unit_relation_data(relation.data[self.model.unit]) + requirer_relation_csr_list = relation_data.get("certificate_signing_requests") + if not requirer_relation_csr_list: + logger.info("No CSR in relation data.") + return + for requirer_csr in requirer_relation_csr_list: + if ( + requirer_csr["certificate_signing_request"] + == certificate_signing_request.decode().strip() + ): + requirer_relation_csr_list.remove(requirer_csr) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps( + requirer_relation_csr_list + ) + logger.info("Certificate revocation sent to provider") + + def request_certificate_renewal( + self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes + ) -> None: + """Renews certificate. + + Removes old CSR from relation data and adds new one. + + Args: + old_certificate_signing_request: Old CSR + new_certificate_signing_request: New CSR + + Returns: + None + """ + try: + self.request_certificate_revocation( + certificate_signing_request=old_certificate_signing_request + ) + except RuntimeError: + logger.warning("Certificate revocation failed.") + self.request_certificate_creation( + certificate_signing_request=new_certificate_signing_request + ) + logger.info("Certificate renewal request completed.") + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Checks whether relation data is valid based on json schema. + + Args: + certificates_data: Certificate data in dict format. + + Returns: + bool: Whether relation data is valid. + """ + try: + validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggerred on relation changed events. + + Args: + event: Juju event + + Returns: + None + """ + provider_relation_data = _load_unit_relation_data(event.relation.data[event.unit]) + if not self._relation_data_is_valid(provider_relation_data): + logger.warning( + f"Relation data did not pass JSON Schema validation: {provider_relation_data}" + ) + return + provider_csrs = _get_provider_csrs(event.relation.data[event.unit]) + requirer_csrs = _get_requirer_csrs(event.relation.data[self.model.unit]) + for certificate in provider_relation_data["certificates"]: + if certificate["certificate_signing_request"] in requirer_csrs: + self.on.certificate_available.emit( + certificate_signing_request=certificate["certificate_signing_request"], + certificate=certificate["certificate"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + for csr in requirer_csrs: + if csr not in provider_csrs: + self.on.certificate_revoked.emit(certificate_signing_request=csr) + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Triggered on update status event. + + Goes through each certificate in the "certificates" relation and checks their expiry date. + If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if + they are expired, emits a CertificateExpiredEvent. + + Args: + event (UpdateStatusEvent): Juju event + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + return + for unit in relation.units: + relation_data = _load_unit_relation_data(relation.data[unit]) + if self._relation_data_is_valid(relation_data): + certificates = relation_data.get("certificates") + if not certificates: + continue + for certificate_dict in certificates: + certificate = certificate_dict["certificate"] + certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) + time_difference = certificate_object.not_valid_after - datetime.utcnow() + if time_difference.total_seconds() < 0: + logger.warning("Certificate is expired") + self.on.certificate_expired.emit(certificate=certificate) + self.request_certificate_revocation(certificate) + continue + if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): + logger.info("Certificate almost expired") + self.on.certificate_expiring.emit( + certificate=certificate, expiry=certificate_object.not_valid_after + ) diff --git a/metadata.yaml b/metadata.yaml index 1c1a7af8a7..a58452ab49 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -15,6 +15,8 @@ series: peers: database-peers: interface: postgresql_peers + restart: + interface: rolling_op provides: database: @@ -24,6 +26,11 @@ provides: db-admin: interface: pgsql +requires: + certificates: + interface: tls-certificates + limit: 1 + resources: patroni: type: file diff --git a/requirements.txt b/requirements.txt index 62dd6a8235..0241ab4e8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +cryptography==37.0.4 +jsonschema==4.14.0 ops==1.5.0 pgconnstr==1.0.1 requests==2.28.1 From 1560781e294e63bc6c7ddc8509988b875601b641 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 6 Sep 2022 10:40:02 -0300 Subject: [PATCH 20/34] Import files --- charmcraft.yaml | 5 + .../postgresql_k8s/v0/postgresql_tls.py | 192 +++ lib/charms/rolling_ops/v0/rollingops.py | 390 ++++++ .../v1/tls_certificates.py | 1204 +++++++++++++++++ metadata.yaml | 7 + requirements.txt | 2 + 6 files changed, 1800 insertions(+) create mode 100644 lib/charms/postgresql_k8s/v0/postgresql_tls.py create mode 100644 lib/charms/rolling_ops/v0/rollingops.py create mode 100644 lib/charms/tls_certificates_interface/v1/tls_certificates.py diff --git a/charmcraft.yaml b/charmcraft.yaml index 3bbd6d53f2..2d17c43ed0 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -11,4 +11,9 @@ bases: channel: "20.04" parts: charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo charm-binary-python-packages: [psycopg2-binary==2.9.3] diff --git a/lib/charms/postgresql_k8s/v0/postgresql_tls.py b/lib/charms/postgresql_k8s/v0/postgresql_tls.py new file mode 100644 index 0000000000..5f1a0c2955 --- /dev/null +++ b/lib/charms/postgresql_k8s/v0/postgresql_tls.py @@ -0,0 +1,192 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +"""In this class we manage certificates relation. + +This class handles certificate request and renewal through +the interaction with the TLS Certificates Operator. + +This library needs that https://charmhub.io/tls-certificates-interface/libraries/tls_certificates +library is imported to work. + +It also needs the following methods in the charm class: +— get_hostname_by_unit: to retrieve the DNS hostname of the unit. +— get_secret: to retrieve TLS files from secrets. +— push_tls_files_to_workload: to push TLS files to the workload container and enable TLS. +— set_secret: to store TLS files as secrets. +— update_config: to disable TLS when relation with the TLS Certificates Operator is broken. +""" + +import base64 +import logging +import re +import socket +from typing import List, Optional + +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + TLSCertificatesRequiresV1, + generate_csr, + generate_private_key, +) +from cryptography import x509 +from cryptography.x509.extensions import ExtensionType +from ops.charm import ActionEvent +from ops.framework import Object +from ops.pebble import PathError, ProtocolError + +# The unique Charmhub library identifier, never change it +LIBID = "c27af44a92df4ef38d7ae06418b2800f" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version. +LIBPATCH = 1 + +logger = logging.getLogger(__name__) +SCOPE = "unit" +TLS_RELATION = "certificates" + + +class PostgreSQLTLS(Object): + """In this class we manage certificates relation.""" + + def __init__(self, charm, peer_relation): + """Manager of PostgreSQL relation with TLS Certificates Operator.""" + super().__init__(charm, "client-relations") + self.charm = charm + self.peer_relation = peer_relation + self.certs = TLSCertificatesRequiresV1(self.charm, TLS_RELATION) + self.framework.observe( + self.charm.on.set_tls_private_key_action, self._on_set_tls_private_key + ) + self.framework.observe( + self.charm.on[TLS_RELATION].relation_joined, self._on_tls_relation_joined + ) + self.framework.observe( + self.charm.on[TLS_RELATION].relation_broken, self._on_tls_relation_broken + ) + self.framework.observe(self.certs.on.certificate_available, self._on_certificate_available) + self.framework.observe(self.certs.on.certificate_expiring, self._on_certificate_expiring) + + def _on_set_tls_private_key(self, event: ActionEvent) -> None: + """Set the TLS private key, which will be used for requesting the certificate.""" + self._request_certificate(event.params.get("private-key", None)) + + def _request_certificate(self, param: Optional[str]): + """Request a certificate to TLS Certificates Operator.""" + if param is None: + key = generate_private_key() + else: + key = self._parse_tls_file(param) + + csr = generate_csr( + private_key=key, + subject=self.charm.get_hostname_by_unit(self.charm.unit.name), + sans=self._get_sans(), + additional_critical_extensions=self._get_tls_extensions(), + ) + + self.charm.set_secret(SCOPE, "key", key.decode("utf-8")) + self.charm.set_secret(SCOPE, "csr", csr.decode("utf-8")) + + if self.charm.model.get_relation(TLS_RELATION): + self.certs.request_certificate_creation(certificate_signing_request=csr) + + @staticmethod + def _parse_tls_file(raw_content: str) -> bytes: + """Parse TLS files from both plain text or base64 format.""" + plain_text_tls_file_regex = r"(-+(BEGIN|END) [A-Z ]+-+)" + if re.match(plain_text_tls_file_regex, raw_content): + return re.sub( + plain_text_tls_file_regex, + "\\1", + raw_content, + ).encode("utf-8") + return base64.b64decode(raw_content) + + def _on_tls_relation_joined(self, _) -> None: + """Request certificate when TLS relation joined.""" + self._request_certificate(None) + + def _on_tls_relation_broken(self, _) -> None: + """Disable TLS when TLS relation broken.""" + self.charm.set_secret(SCOPE, "ca", None) + self.charm.set_secret(SCOPE, "cert", None) + self.charm.set_secret(SCOPE, "chain", None) + self.charm.update_config() + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + """Enable TLS when TLS certificate available.""" + if event.certificate_signing_request != self.charm.get_secret(SCOPE, "csr"): + logger.error("An unknown certificate expiring.") + return + + self.charm.set_secret(SCOPE, "chain", event.chain) + self.charm.set_secret(SCOPE, "cert", event.certificate) + self.charm.set_secret(SCOPE, "ca", event.ca) + + try: + self.charm.push_tls_files_to_workload() + except (PathError, ProtocolError) as e: + logger.error("Cannot push TLS certificates: %r", e) + event.defer() + return + + def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: + """Request the new certificate when old certificate is expiring.""" + if event.certificate != self.charm.get_secret(SCOPE, "cert"): + logger.error("An unknown certificate expiring.") + return + + key = self.charm.get_secret(SCOPE, "key").encode("utf-8") + old_csr = self.charm.get_secret(SCOPE, "csr").encode("utf-8") + new_csr = generate_csr( + private_key=key, + subject=self.charm.get_hostname_by_unit(self.charm.unit.name), + sans=self._get_sans(), + additional_critical_extensions=self._get_tls_extensions(), + ) + self.certs.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + self.charm.set_secret(SCOPE, "csr", new_csr.decode("utf-8")) + + def _get_sans(self) -> List[str]: + """Create a list of DNS names for a PostgreSQL unit. + + Returns: + A list representing the hostnames of the PostgreSQL unit. + """ + unit_id = self.charm.unit.name.split("/")[1] + return [ + f"{self.charm.app.name}-{unit_id}", + socket.getfqdn(), + str(self.charm.model.get_binding(self.peer_relation).network.bind_address), + ] + + @staticmethod + def _get_tls_extensions() -> Optional[List[ExtensionType]]: + """Return a list of TLS extensions for which certificate key can be used.""" + basic_constraints = x509.BasicConstraints(ca=True, path_length=None) + return [basic_constraints] + + def get_tls_files(self) -> (Optional[str], Optional[str]): + """Prepare TLS files in special PostgreSQL way. + + PostgreSQL needs three files: + — CA file should have a full chain. + — Key file should have private key. + — Certificate file should have certificate without certificate chain. + """ + ca = self.charm.get_secret(SCOPE, "ca") + chain = self.charm.get_secret(SCOPE, "chain") + ca_file = chain if chain else ca + + key = self.charm.get_secret(SCOPE, "key") + cert = self.charm.get_secret(SCOPE, "cert") + return key, ca_file, cert diff --git a/lib/charms/rolling_ops/v0/rollingops.py b/lib/charms/rolling_ops/v0/rollingops.py new file mode 100644 index 0000000000..bca24b07fb --- /dev/null +++ b/lib/charms/rolling_ops/v0/rollingops.py @@ -0,0 +1,390 @@ +# Copyright 2022 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This library enables "rolling" operations across units of a charmed Application. + +For example, a charm author might use this library to implement a "rolling restart", in +which all units in an application restart their workload, but no two units execute the +restart at the same time. + +To implement the rolling restart, a charm author would do the following: + +1. Add a peer relation called 'restart' to a charm's `metadata.yaml`: +```yaml +peers: + restart: + interface: rolling_op +``` + +Import this library into src/charm.py, and initialize a RollingOpsManager in the Charm's +`__init__`. The Charm should also define a callback routine, which will be executed when +a unit holds the distributed lock: + +src/charm.py +```python +# ... +from charms.rolling_ops.v0.rollingops import RollingOpsManager +# ... +class SomeCharm(...): + def __init__(...) + # ... + self.restart_manager = RollingOpsManager( + charm=self, relation="restart", callback=self._restart + ) + # ... + def _restart(self, event): + systemd.service_restart('foo') +``` + +To kick off the rolling restart, emit this library's AcquireLock event. The simplest way +to do so would be with an action, though it might make sense to acquire the lock in +response to another event. + +```python + def _on_trigger_restart(self, event): + self.charm.on[self.restart_manager.name].acquire_lock.emit() +``` + +In order to trigger the restart, a human operator would execute the following command on +the CLI: + +``` +juju run-action some-charm/0 some-charm/1 <... some-charm/n> restart +``` + +Note that all units that plan to restart must receive the action and emit the aquire +event. Any units that do not run their acquire handler will be left out of the rolling +restart. (An operator might take advantage of this fact to recover from a failed rolling +operation without restarting workloads that were able to successfully restart -- simply +omit the successful units from a subsequent run-action call.) + +""" +import logging +from enum import Enum +from typing import AnyStr, Callable + +from ops.charm import ActionEvent, CharmBase, RelationChangedEvent +from ops.framework import EventBase, Object +from ops.model import ActiveStatus, MaintenanceStatus, WaitingStatus + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "20b7777f58fe421e9a223aefc2b4d3a4" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 2 + + +class LockNoRelationError(Exception): + """Raised if we are trying to process a lock, but do not appear to have a relation yet.""" + + pass + + +class LockState(Enum): + """Possible states for our Distributed lock. + + Note that there are two states set on the unit, and two on the application. + + """ + + ACQUIRE = "acquire" + RELEASE = "release" + GRANTED = "granted" + IDLE = "idle" + + +class Lock: + """A class that keeps track of a single asynchronous lock. + + Warning: a Lock has permission to update relation data, which means that there are + side effects to invoking the .acquire, .release and .grant methods. Running any one of + them will trigger a RelationChanged event, once per transition from one internal + status to another. + + This class tracks state across the cloud by implementing a peer relation + interface. There are two parts to the interface: + + 1) The data on a unit's peer relation (defined in metadata.yaml.) Each unit can update + this data. The only meaningful values are "acquire", and "release", which represent + a request to acquire the lock, and a request to release the lock, respectively. + + 2) The application data in the relation. This tracks whether the lock has been + "granted", Or has been released (and reverted to idle). There are two valid states: + "granted" or None. If a lock is in the "granted" state, a unit should emit a + RunWithLocks event and then release the lock. + + If a lock is in "None", this means that a unit has not yet requested the lock, or + that the request has been completed. + + In more detail, here is the relation structure: + + relation.data: + : + status: 'acquire|release' + : + : 'granted|None' + + Note that this class makes no attempts to timestamp the locks and thus handle multiple + requests in a row. If a unit re-requests a lock before being granted the lock, the + lock will simply stay in the "acquire" state. If a unit wishes to clear its lock, it + simply needs to call lock.release(). + + """ + + def __init__(self, manager, unit=None): + + self.relation = manager.model.relations[manager.name][0] + if not self.relation: + # TODO: defer caller in this case (probably just fired too soon). + raise LockNoRelationError() + + self.unit = unit or manager.model.unit + self.app = manager.model.app + + @property + def _state(self) -> LockState: + """Return an appropriate state. + + Note that the state exists in the unit's relation data, and the application + relation data, so we have to be careful about what our states mean. + + Unit state can only be in "acquire", "release", "None" (None means unset) + Application state can only be in "granted" or "None" (None means unset or released) + + """ + unit_state = LockState(self.relation.data[self.unit].get("state", LockState.IDLE.value)) + app_state = LockState( + self.relation.data[self.app].get(str(self.unit), LockState.IDLE.value) + ) + + if app_state == LockState.GRANTED and unit_state == LockState.RELEASE: + # Active release request. + return LockState.RELEASE + + if app_state == LockState.IDLE and unit_state == LockState.ACQUIRE: + # Active acquire request. + return LockState.ACQUIRE + + return app_state # Granted or unset/released + + @_state.setter + def _state(self, state: LockState): + """Set the given state. + + Since we update the relation data, this may fire off a RelationChanged event. + """ + if state == LockState.ACQUIRE: + self.relation.data[self.unit].update({"state": state.value}) + + if state == LockState.RELEASE: + self.relation.data[self.unit].update({"state": state.value}) + + if state == LockState.GRANTED: + self.relation.data[self.app].update({str(self.unit): state.value}) + + if state is LockState.IDLE: + self.relation.data[self.app].update({str(self.unit): state.value}) + + def acquire(self): + """Request that a lock be acquired.""" + self._state = LockState.ACQUIRE + + def release(self): + """Request that a lock be released.""" + self._state = LockState.RELEASE + + def clear(self): + """Unset a lock.""" + self._state = LockState.IDLE + + def grant(self): + """Grant a lock to a unit.""" + self._state = LockState.GRANTED + + def is_held(self): + """This unit holds the lock.""" + return self._state == LockState.GRANTED + + def release_requested(self): + """A unit has reported that they are finished with the lock.""" + return self._state == LockState.RELEASE + + def is_pending(self): + """Is this unit waiting for a lock?""" + return self._state == LockState.ACQUIRE + + +class Locks: + """Generator that returns a list of locks.""" + + def __init__(self, manager): + self.manager = manager + + # Gather all the units. + relation = manager.model.relations[manager.name][0] + units = [unit for unit in relation.units] + + # Plus our unit ... + units.append(manager.model.unit) + + self.units = units + + def __iter__(self): + """Yields a lock for each unit we can find on the relation.""" + for unit in self.units: + yield Lock(self.manager, unit=unit) + + +class RunWithLock(EventBase): + """Event to signal that this unit should run the callback.""" + + pass + + +class AcquireLock(EventBase): + """Signals that this unit wants to acquire a lock.""" + + pass + + +class ProcessLocks(EventBase): + """Used to tell the leader to process all locks.""" + + pass + + +class RollingOpsManager(Object): + """Emitters and handlers for rolling ops.""" + + def __init__(self, charm: CharmBase, relation: AnyStr, callback: Callable): + """Register our custom events. + + params: + charm: the charm we are attaching this to. + relation: an identifier, by convention based on the name of the relation in the + metadata.yaml, which identifies this instance of RollingOperatorsFactory, + distinct from other instances that may be hanlding other events. + callback: a closure to run when we have a lock. (It must take a CharmBase object and + EventBase object as args.) + """ + # "Inherit" from the charm's class. This gives us access to the framework as + # self.framework, as well as the self.model shortcut. + super().__init__(charm, None) + + self.name = relation + self._callback = callback + self.charm = charm # Maintain a reference to charm, so we can emit events. + + charm.on.define_event("{}_run_with_lock".format(self.name), RunWithLock) + charm.on.define_event("{}_acquire_lock".format(self.name), AcquireLock) + charm.on.define_event("{}_process_locks".format(self.name), ProcessLocks) + + # Watch those events (plus the built in relation event). + self.framework.observe(charm.on[self.name].relation_changed, self._on_relation_changed) + self.framework.observe(charm.on[self.name].acquire_lock, self._on_acquire_lock) + self.framework.observe(charm.on[self.name].run_with_lock, self._on_run_with_lock) + self.framework.observe(charm.on[self.name].process_locks, self._on_process_locks) + + def _callback(self: CharmBase, event: EventBase) -> None: + """Placeholder for the function that actually runs our event. + + Usually overridden in the init. + """ + raise NotImplementedError + + def _on_relation_changed(self: CharmBase, event: RelationChangedEvent): + """Process relation changed. + + First, determine whether this unit has been granted a lock. If so, emit a RunWithLock + event. + + Then, if we are the leader, fire off a process locks event. + + """ + lock = Lock(self) + + if lock.is_pending(): + self.model.unit.status = WaitingStatus("Awaiting {} operation".format(self.name)) + + if lock.is_held(): + self.charm.on[self.name].run_with_lock.emit() + + if self.model.unit.is_leader(): + self.charm.on[self.name].process_locks.emit() + + def _on_process_locks(self: CharmBase, event: ProcessLocks): + """Process locks. + + Runs only on the leader. Updates the status of all locks. + + """ + if not self.model.unit.is_leader(): + return + + pending = [] + + for lock in Locks(self): + if lock.is_held(): + # One of our units has the lock -- return without further processing. + return + + if lock.release_requested(): + lock.clear() # Updates relation data + + if lock.is_pending(): + if lock.unit == self.model.unit: + # Always run on the leader last. + pending.insert(0, lock) + else: + pending.append(lock) + + # If we reach this point, and we have pending units, we want to grant a lock to + # one of them. + if pending: + self.model.app.status = MaintenanceStatus("Beginning rolling {}".format(self.name)) + lock = pending[-1] + lock.grant() + if lock.unit == self.model.unit: + # It's time for the leader to run with lock. + self.charm.on[self.name].run_with_lock.emit() + return + + self.model.app.status = ActiveStatus() + + def _on_acquire_lock(self: CharmBase, event: ActionEvent): + """Request a lock.""" + try: + Lock(self).acquire() # Updates relation data + # emit relation changed event in the edge case where aquire does not + relation = self.model.get_relation(self.name) + self.charm.on[self.name].relation_changed.emit(relation) + except LockNoRelationError: + logger.debug("No {} peer relation yet. Delaying rolling op.".format(self.name)) + event.defer() + + def _on_run_with_lock(self: CharmBase, event: RunWithLock): + lock = Lock(self) + self.model.unit.status = MaintenanceStatus("Executing {} operation".format(self.name)) + self._callback(event) + lock.release() # Updates relation data + if lock.unit == self.model.unit: + self.charm.on[self.name].process_locks.emit() + + self.model.unit.status = ActiveStatus() diff --git a/lib/charms/tls_certificates_interface/v1/tls_certificates.py b/lib/charms/tls_certificates_interface/v1/tls_certificates.py new file mode 100644 index 0000000000..60432bb35c --- /dev/null +++ b/lib/charms/tls_certificates_interface/v1/tls_certificates.py @@ -0,0 +1,1204 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the tls-certificates relation. + +This library contains the Requires and Provides classes for handling the tls-certificates +interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.tls_certificates_interface.v1.tls_certificates +``` + +Add the following libraries to the charm's `requirements.txt` file: +- jsonschema +- cryptography + +Add the following section to the charm's `charmcraft.yaml` file: +```yaml +parts: + charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo +``` + +### Provider charm +The provider charm is the charm providing certificates to another charm that requires them. In +this example, the provider charm is storing its private key using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateCreationRequestEvent, + CertificateRevocationRequestEvent, + TLSCertificatesProvidesV1, + generate_private_key, +) +from ops.charm import CharmBase, InstallEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +def generate_ca(private_key: bytes, subject: str) -> str: + return "whatever ca content" + + +def generate_certificate(ca: str, private_key: str, csr: str) -> str: + return "Whatever certificate" + + +class ExampleProviderCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.certificates = TLSCertificatesProvidesV1(self, "certificates") + self.framework.observe( + self.certificates.on.certificate_request, self._on_certificate_request + ) + self.framework.observe( + self.certificates.on.certificate_revoked, self._on_certificate_revoked + ) + self.framework.observe(self.on.install, self._on_install) + + def _on_install(self, event: InstallEvent) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + ca_certificate = generate_ca(private_key=private_key, subject="whatever") + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + { + "private_key_password": "banana", + "private_key": private_key, + "ca_certificate": ca_certificate, + } + ) + self.unit.status = ActiveStatus() + + def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + ca_certificate = replicas_relation.data[self.app].get("ca_certificate") + private_key = replicas_relation.data[self.app].get("private_key") + certificate = generate_certificate( + ca=ca_certificate, + private_key=private_key, + csr=event.certificate_signing_request, + ) + + self.certificates.set_relation_certificate( + certificate=certificate, + certificate_signing_request=event.certificate_signing_request, + ca=ca_certificate, + chain=ca_certificate, + relation_id=event.relation_id, + ) + + def _on_certificate_revoked(self, event: CertificateRevocationRequestEvent) -> None: + # Do what you want to do with this information + pass + + +if __name__ == "__main__": + main(ExampleProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. In +this example, the requirer charm is storing its certificates using a peer relation interface called +`replicas`. + +Example: +```python + +from charms.tls_certificates_interface.v1.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + CertificateRevokedEvent, + TLSCertificatesRequiresV1, + generate_csr, + generate_private_key, +) +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +class ExampleRequirerCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.cert_subject = "whatever" + self.certificates = TLSCertificatesRequiresV1(self, "certificates") + self.framework.observe(self.on.install, self._on_install) + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + self.framework.observe( + self.certificates.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.on.certificates.on.certificate_expiring, self._on_certificate_expiring + ) + + def _on_install(self, event) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + {"private_key_password": "banana", "private_key": private_key.decode()} + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + replicas_relation.data[self.app].update({"csr": csr.decode()}) + self.certificates.request_certificate_creation(certificate_signing_request=csr) + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update({"certificate": event.certificate}) + replicas_relation.data[self.app].update({"ca": event.ca}) + replicas_relation.data[self.app].update({"chain": event.chain}) + self.unit.status = ActiveStatus() + + def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + old_csr = replicas_relation.data[self.app].get("csr") + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + + +if __name__ == "__main__": + main(ExampleRequirerCharm) +``` +""" # noqa: D405, D410, D411, D214, D416 + +import copy +import json +import logging +import uuid +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from jsonschema import exceptions, validate # type: ignore[import] +from ops.charm import CharmBase, CharmEvents, RelationChangedEvent, UpdateStatusEvent +from ops.framework import EventBase, EventSource, Handle, Object + +# The unique Charmhub library identifier, never change it +LIBID = "afd8c2bccf834997afce12c2706d2ede" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 5 + +REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/requirer.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` requirer root schema", + "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 + "examples": [ + { + "certificate_signing_requests": [ + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + ] + } + ], + "properties": { + "certificate_signing_requests": { + "type": "array", + "items": { + "type": "object", + "properties": {"certificate_signing_request": {"type": "string"}}, + "required": ["certificate_signing_request"], + }, + } + }, + "required": ["certificate_signing_requests"], + "additionalProperties": True, +} + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v1/schemas/provider.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` provider root schema", + "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 + "example": [ + { + "certificates": [ + { + "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 + "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 + } + ] + } + ], + "properties": { + "certificates": { + "$id": "#/properties/certificates", + "type": "array", + "items": { + "$id": "#/properties/certificates/items", + "type": "object", + "required": ["certificate_signing_request", "certificate", "ca", "chain"], + "properties": { + "certificate_signing_request": { + "$id": "#/properties/certificates/items/certificate_signing_request", + "type": "string", + }, + "certificate": { + "$id": "#/properties/certificates/items/certificate", + "type": "string", + }, + "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, + "chain": {"$id": "#/properties/certificates/items/chain", "type": "string"}, + }, + "additionalProperties": True, + }, + } + }, + "required": ["certificates"], + "additionalProperties": True, +} + + +logger = logging.getLogger(__name__) + + +class CertificateAvailableEvent(EventBase): + """Charm Event triggered when a TLS certificate is available.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class CertificateExpiringEvent(EventBase): + """Charm Event triggered when a TLS certificate is almost expired.""" + + def __init__(self, handle, certificate: str, expiry: datetime): + """CertificateExpiringEvent. + + Args: + handle (Handle): Juju framework handle + certificate (str): TLS Certificate + expiry (datetime): Datetime object reprensenting the time at which the certificate + won't be valid anymore. + """ + super().__init__(handle) + self.certificate = certificate + self.expiry = expiry + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate, "expiry": self.expiry} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.expiry = snapshot["expiry"] + + +class CertificateExpiredEvent(EventBase): + """Charm Event triggered when a TLS certificate is expired.""" + + def __init__(self, handle: Handle, certificate: str): + super().__init__(handle) + self.certificate = certificate + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + + +class CertificateCreationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate is required.""" + + def __init__(self, handle: Handle, certificate_signing_request: str, relation_id: int): + super().__init__(handle) + self.certificate_signing_request = certificate_signing_request + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate_signing_request": self.certificate_signing_request, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.relation_id = snapshot["relation_id"] + + +class CertificateRevocationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate needs to be revoked.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class CertificateRevokedEvent(EventBase): + """Charm Event triggered when a TLS certificate is revoked.""" + + def __init__(self, handle, certificate_signing_request: str): + super().__init__(handle) + self.certificate_signing_request = certificate_signing_request + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate_signing_request": self.certificate_signing_request} + + def restore(self, snapshot): + """Restores snapshot.""" + self.certificate_signing_request = snapshot["certificate_signing_request"] + + +def _load_relation_data(raw_relation_data: dict) -> dict: + """Loads relation data from the relation data bag. + + Json loads all data. + + Args: + raw_relation_data: Relation data from the databag + + Returns: + dict: Relation data in dict format. + """ + certificate_data = dict() + for key in raw_relation_data: + try: + certificate_data[key] = json.loads(raw_relation_data[key]) + except json.decoder.JSONDecodeError: + certificate_data[key] = raw_relation_data[key] + return certificate_data + + +def generate_ca( + private_key: bytes, + subject: str, + private_key_password: Optional[bytes] = None, + validity: int = 365, + country: str = "US", +) -> bytes: + """Generates a CA Certificate. + + Args: + private_key (bytes): Private key + subject (str): Certificate subject + private_key_password (bytes): Private key password + validity (int): Certificate validity time (in days) + country (str): Certificate Issuing country + + Returns: + bytes: CA Certificate. + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), + x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), + ] + ) + subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( + private_key_object.public_key() # type: ignore[arg-type] + ) + subject_identifier = key_identifier = subject_identifier_object.public_bytes() + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key_object.public_key()) # type: ignore[arg-type] + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) + .add_extension( + x509.AuthorityKeyIdentifier( + key_identifier=key_identifier, + authority_cert_issuer=None, + authority_cert_serial_number=None, + ), + critical=False, + ) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] + ) + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_certificate( + csr: bytes, + ca: bytes, + ca_key: bytes, + ca_key_password: Optional[bytes] = None, + validity: int = 365, + alt_names: list = None, +) -> bytes: + """Generates a TLS certificate based on a CSR. + + Args: + csr (bytes): CSR + ca (bytes): CA Certificate + ca_key (bytes): CA private key + ca_key_password: CA private key password + validity (int): Certificate validity (in days) + alt_names: Certificate Subject alternative names + + Returns: + bytes: Certificate + """ + csr_object = x509.load_pem_x509_csr(csr) + subject = csr_object.subject + issuer = x509.load_pem_x509_certificate(ca).issuer + private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) + + certificate_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(csr_object.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + ) + if alt_names: + names = [x509.DNSName(n) for n in alt_names] + certificate_builder = certificate_builder.add_extension( + x509.SubjectAlternativeName(names), + critical=False, + ) + certificate_builder._version = x509.Version.v1 + cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_pfx_package( + certificate: bytes, + private_key: bytes, + package_password: str, + private_key_password: Optional[bytes] = None, +) -> bytes: + """Generates a PFX package to contain the TLS certificate and private key. + + Args: + certificate (bytes): TLS certificate + private_key (bytes): Private key + package_password (str): Password to open the PFX package + private_key_password (bytes): Private key password + + Returns: + bytes: + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + certificate_object = x509.load_pem_x509_certificate(certificate) + name = certificate_object.subject.rfc4514_string() + pfx_bytes = pkcs12.serialize_key_and_certificates( + name=name.encode(), + cert=certificate_object, + key=private_key_object, # type: ignore[arg-type] + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), + ) + return pfx_bytes + + +def generate_private_key( + password: Optional[bytes] = None, + key_size: int = 2048, + public_exponent: int = 65537, +) -> bytes: + """Generates a private key. + + Args: + password (bytes): Password for decrypting the private key + key_size (int): Key size in bytes + public_exponent: Public exponent. + + Returns: + bytes: Private Key + """ + private_key = rsa.generate_private_key( + public_exponent=public_exponent, + key_size=key_size, + ) + key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption(password) + if password + else serialization.NoEncryption(), + ) + return key_bytes + + +def generate_csr( + private_key: bytes, + subject: str, + add_unique_id_to_subject_name: bool = True, + organization: str = None, + email_address: str = None, + country_name: str = None, + private_key_password: Optional[bytes] = None, + sans: Optional[List[str]] = None, + additional_critical_extensions: Optional[List] = None, +) -> bytes: + """Generates a CSR using private key and subject. + + Args: + private_key (bytes): Private key + subject (str): CSR Subject. + add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's + subject name. Always leave to "True" when the CSR is used to request certificates + using the tls-certificates relation. + organization (str): Name of organization. + email_address (str): Email address. + country_name (str): Country Name. + private_key_password (bytes): Private key password + sans (list): List of subject alternative names + additional_critical_extensions (list): List if critical additional extension objects. + Object must be a x509 ExtensionType. + + Returns: + bytes: CSR + """ + signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) + subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] + if add_unique_id_to_subject_name: + unique_identifier = uuid.uuid4() + subject_name.append( + x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) + ) + if organization: + subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) + if email_address: + subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) + if country_name: + subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) + csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) + if sans: + csr = csr.add_extension( + x509.SubjectAlternativeName([x509.DNSName(san) for san in sans]), critical=False + ) + if additional_critical_extensions: + for extension in additional_critical_extensions: + csr = csr.add_extension(extension, critical=True) + signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] + return signed_certificate.public_bytes(serialization.Encoding.PEM) + + +class CertificatesProviderCharmEvents(CharmEvents): + """List of events that the TLS Certificates provider charm can leverage.""" + + certificate_creation_request = EventSource(CertificateCreationRequestEvent) + certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) + + +class CertificatesRequirerCharmEvents(CharmEvents): + """List of events that the TLS Certificates requirer charm can leverage.""" + + certificate_available = EventSource(CertificateAvailableEvent) + certificate_expiring = EventSource(CertificateExpiringEvent) + certificate_expired = EventSource(CertificateExpiredEvent) + + +class TLSCertificatesProvidesV1(Object): + """TLS certificates provider class to be instantiated by TLS certificates providers.""" + + on = CertificatesProviderCharmEvents() + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.charm = charm + self.relationship_name = relationship_name + + @property + def _provider_certificates(self) -> List[Dict[str, str]]: + """Returns list of provider CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + provider_relation_data = _load_relation_data(relation.data[self.model.app]) + return provider_relation_data.get("certificates", []) + + def _requirer_csrs(self, unit) -> List[Dict[str, str]]: + """Returns list of requirer CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + requirer_relation_data = _load_relation_data(relation.data[unit]) + return requirer_relation_data.get("certificate_signing_requests", []) + + def _add_certificate( + self, certificate: str, certificate_signing_request: str, ca: str, chain: str + ) -> None: + """Adds certificate to relation data. + + Args: + certificate (str): Certificate + certificate_signing_request (str): Certificate Signing Request + ca (str): CA Certificate + chain (str): CA Chain + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_certificate = { + "certificate": certificate, + "certificate_signing_request": certificate_signing_request, + "ca": ca, + "chain": chain, + } + certificates = copy.deepcopy(self._provider_certificates) + if new_certificate in certificates: + logger.info("Certificate already in relation data - Doing nothing") + return + certificates.append(new_certificate) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + def _remove_certificate( + self, + relation_id: int, + certificate: str = None, + certificate_signing_request: str = None, + ) -> None: + """Removes certificate from a given relation based on user provided certificate or csr. + + Args: + relation_id (int): Relation id + certificate (str): Certificate (optional) + certificate_signing_request: Certificate signing request (optional) + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} with relation id {relation_id} does not exist" + ) + certificates = copy.deepcopy(self._provider_certificates) + for certificate_dict in certificates: + if certificate and certificate_dict["certificate"] == certificate: + certificates.remove(certificate_dict) + if ( + certificate_signing_request + and certificate_dict["certificate_signing_request"] == certificate_signing_request + ): + certificates.remove(certificate_dict) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Uses JSON schema validator to validate relation data content. + + Args: + certificates_data (dict): Certificate data dictionary as retrieved from relation data. + + Returns: + bool: True/False depending on whether the relation data follows the json schema. + """ + try: + validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def set_relation_certificate( + self, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + relation_id: int, + ) -> None: + """Adds certificates to relation data. + + Args: + certificate (str): Certificate + certificate_signing_request (str): Certificate signing request + ca (str): CA Certificate + chain (str): CA Chain certificate + relation_id (int): Juju relation ID + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + self._remove_certificate( + certificate_signing_request=certificate_signing_request.strip(), + relation_id=relation_id, + ) + self._add_certificate( + certificate=certificate.strip(), + certificate_signing_request=certificate_signing_request.strip(), + ca=ca.strip(), + chain=chain.strip(), + ) + + def remove_certificate(self, certificate: str) -> None: + """Removes a given certificate from relation data. + + Args: + certificate (str): TLS Certificate + + Returns: + None + """ + certificates_relation = self.model.relations[self.relationship_name] + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + for certificate_relation in certificates_relation: + self._remove_certificate(certificate=certificate, relation_id=certificate_relation.id) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggerred on relation changed event. + + Looks at the relation data and either emits: + - certificate request event: If the unit relation data contains a CSR for which + a certificate does not exist in the provider relation data. + - certificate revocation event: If the provider relation data contains a CSR for which + a csr does not exist in the requirer relation data. + + Args: + event: Juju event + + Returns: + None + """ + requirer_relation_data = _load_relation_data(event.relation.data[event.unit]) + if not self._relation_data_is_valid(requirer_relation_data): + logger.warning( + f"Relation data did not pass JSON Schema validation: {requirer_relation_data}" + ) + return + provider_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in self._provider_certificates + ] + requirer_unit_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in self._requirer_csrs(event.unit) + ] + for certificate_signing_request in requirer_unit_csrs: + if certificate_signing_request not in provider_csrs: + self.on.certificate_creation_request.emit( + certificate_signing_request=certificate_signing_request, + relation_id=event.relation.id, + ) + self._revoke_certificates_for_which_no_csr_exists(relation_id=event.relation.id) + + def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None: + """Revokes certificates for which no unit has a CSR. + + Goes through all generated certificates and compare agains the list of CSRS for all units + of a given relationship. + + Args: + relation_id (int): Relation id + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + list_of_csrs: List[str] = [] + for unit in certificates_relation.units: + list_of_csrs.extend( + csr["certificate_signing_request"] for csr in self._requirer_csrs(unit) + ) + for certificate in self._provider_certificates: + if certificate["certificate_signing_request"] not in list_of_csrs: + self.on.certificate_revocation_request.emit( + certificate=certificate["certificate"], + certificate_signing_request=certificate["certificate_signing_request"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + self.remove_certificate(certificate=certificate["certificate"]) + + +class TLSCertificatesRequiresV1(Object): + """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" + + on = CertificatesRequirerCharmEvents() + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + expiry_notification_time: int = 168, + ): + """Generates/use private key and observes relation changed event. + + Args: + charm: Charm object + relationship_name: Juju relation name + expiry_notification_time (int): Time difference between now and expiry (in hours). + Used to trigger the CertificateExpiring event. Default: 7 days. + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.expiry_notification_time = expiry_notification_time + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe(charm.on.update_status, self._on_update_status) + + @property + def _requirer_csrs(self) -> List[Dict[str, str]]: + """Returns list of requirer CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + requirer_relation_data = _load_relation_data(relation.data[self.model.unit]) + return requirer_relation_data.get("certificate_signing_requests", []) + + @property + def _provider_certificates(self) -> List[Dict[str, str]]: + """Returns list of provider CSR's from relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + provider_relation_data = _load_relation_data(relation.data[relation.app]) # type: ignore[index] # noqa: E501 + return provider_relation_data.get("certificates", []) + + def _add_requirer_csr(self, csr: str) -> None: + """Adds CSR to relation data. + + Args: + csr (str): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_csr_dict = {"certificate_signing_request": csr} + if new_csr_dict in self._requirer_csrs: + logger.info("CSR already in relation data - Doing nothing") + return + requirer_csrs = copy.deepcopy(self._requirer_csrs) + requirer_csrs.append(new_csr_dict) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def _remove_requirer_csr(self, csr: str) -> None: + """Removes CSR from relation data. + + Args: + csr (str): Certificate signing request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + requirer_csrs = copy.deepcopy(self._requirer_csrs) + csr_dict = {"certificate_signing_request": csr} + if csr_dict not in requirer_csrs: + logger.info("CSR not in relation data - Doing nothing") + return + requirer_csrs.remove(csr_dict) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def request_certificate_creation(self, certificate_signing_request: bytes) -> None: + """Request TLS certificate to provider charm. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + message = ( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + logger.error(message) + raise RuntimeError(message) + self._add_requirer_csr(certificate_signing_request.decode().strip()) + logger.info("Certificate request sent to provider") + + def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: + """Removes CSR from relation data. + + The provider of this relation is then expected to remove certificates associated to this + CSR from the relation data as well and emit a request_certificate_revocation event for the + provider charm to interpret. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + self._remove_requirer_csr(certificate_signing_request.decode().strip()) + logger.info("Certificate revocation sent to provider") + + def request_certificate_renewal( + self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes + ) -> None: + """Renews certificate. + + Removes old CSR from relation data and adds new one. + + Args: + old_certificate_signing_request: Old CSR + new_certificate_signing_request: New CSR + + Returns: + None + """ + try: + self.request_certificate_revocation( + certificate_signing_request=old_certificate_signing_request + ) + except RuntimeError: + logger.warning("Certificate revocation failed.") + self.request_certificate_creation( + certificate_signing_request=new_certificate_signing_request + ) + logger.info("Certificate renewal request completed.") + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Checks whether relation data is valid based on json schema. + + Args: + certificates_data: Certificate data in dict format. + + Returns: + bool: Whether relation data is valid. + """ + try: + validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggerred on relation changed events. + + Args: + event: Juju event + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + logger.warning(f"No relation: {self.relationship_name}") + return + provider_relation_data = _load_relation_data(relation.data[relation.app]) # type: ignore[index] # noqa: E501 + if not self._relation_data_is_valid(provider_relation_data): + logger.warning( + f"Provider relation data did not pass JSON Schema validation: " + f"{event.relation.data[event.app]}" + ) + return + requirer_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in self._requirer_csrs + ] + for certificate in self._provider_certificates: + if certificate["certificate_signing_request"] in requirer_csrs: + self.on.certificate_available.emit( + certificate_signing_request=certificate["certificate_signing_request"], + certificate=certificate["certificate"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Triggered on update status event. + + Goes through each certificate in the "certificates" relation and checks their expiry date. + If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if + they are expired, emits a CertificateExpiredEvent. + + Args: + event (UpdateStatusEvent): Juju event + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + logger.warning(f"No relation: {self.relationship_name}") + return + provider_relation_data = _load_relation_data(relation.data[relation.app]) # type: ignore[index] # noqa: E501 + if not self._relation_data_is_valid(provider_relation_data): + logger.warning( + f"Provider relation data did not pass JSON Schema validation: {relation.data[relation.app]}" # type: ignore[index] # noqa: W505 + ) + return + for certificate_dict in self._provider_certificates: + certificate = certificate_dict["certificate"] + certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) + time_difference = certificate_object.not_valid_after - datetime.utcnow() + if time_difference.total_seconds() < 0: + logger.warning("Certificate is expired") + self.on.certificate_expired.emit(certificate=certificate) + self.request_certificate_revocation(certificate.encode()) + continue + if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): + logger.warning("Certificate almost expired") + self.on.certificate_expiring.emit( + certificate=certificate, expiry=certificate_object.not_valid_after + ) diff --git a/metadata.yaml b/metadata.yaml index 1c1a7af8a7..a58452ab49 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -15,6 +15,8 @@ series: peers: database-peers: interface: postgresql_peers + restart: + interface: rolling_op provides: database: @@ -24,6 +26,11 @@ provides: db-admin: interface: pgsql +requires: + certificates: + interface: tls-certificates + limit: 1 + resources: patroni: type: file diff --git a/requirements.txt b/requirements.txt index 62dd6a8235..0241ab4e8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +cryptography==37.0.4 +jsonschema==4.14.0 ops==1.5.0 pgconnstr==1.0.1 requests==2.28.1 From ee43a399cf3ce70234c557f3dc4d18782598b064 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 7 Sep 2022 15:51:09 -0300 Subject: [PATCH 21/34] Update libraries --- lib/charms/postgresql_k8s/v0/postgresql.py | 56 +++++++++++++++---- .../postgresql_k8s/v0/postgresql_tls.py | 2 +- src/charm.py | 3 +- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/lib/charms/postgresql_k8s/v0/postgresql.py b/lib/charms/postgresql_k8s/v0/postgresql.py index 10b65132c6..f69c3f70bd 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql.py +++ b/lib/charms/postgresql_k8s/v0/postgresql.py @@ -19,7 +19,7 @@ Any charm using this library should import the `psycopg2` or `psycopg2-binary` dependency. """ import logging -from typing import List, Set +from typing import Set import psycopg2 from psycopg2 import sql @@ -32,7 +32,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 2 +LIBPATCH = 5 logger = logging.getLogger(__name__) @@ -67,28 +67,36 @@ class PostgreSQL: def __init__( self, - host: str, + primary_host: str, + current_host: str, user: str, password: str, database: str, ): - self.host = host + self.primary_host = primary_host + self.current_host = current_host self.user = user self.password = password self.database = database - def _connect_to_database(self, database: str = None) -> psycopg2.extensions.connection: + def _connect_to_database( + self, database: str = None, connect_to_current_host: bool = False + ) -> psycopg2.extensions.connection: """Creates a connection to the database. Args: database: database to connect to (defaults to the database provided when the object for this class was created). + connect_to_current_host: whether to connect to the current host + instead of the primary host. Returns: psycopg2 connection object. """ + host = self.current_host if connect_to_current_host else self.primary_host connection = psycopg2.connect( - f"dbname='{database if database else self.database}' user='{self.user}' host='{self.host}' password='{self.password}' connect_timeout=1" + f"dbname='{database if database else self.database}' user='{self.user}' host='{host}'" + f"password='{self.password}' connect_timeout=1" ) connection.autocommit = True return connection @@ -129,14 +137,18 @@ def create_user( try: with self._connect_to_database() as connection, connection.cursor() as cursor: cursor.execute(f"SELECT TRUE FROM pg_roles WHERE rolname='{user}';") - user_definition = f" WITH LOGIN{' SUPERUSER' if admin else ''} ENCRYPTED PASSWORD '{password}'" + user_definition = ( + f" WITH LOGIN{' SUPERUSER' if admin else ''} ENCRYPTED PASSWORD '{password}'" + ) if extra_user_roles: user_definition += f' {extra_user_roles.replace(",", " ")}' if cursor.fetchone() is not None: statement = "ALTER ROLE {}" else: statement = "CREATE ROLE {}" - cursor.execute(sql.SQL(statement + user_definition + ";").format(sql.Identifier(user))) + cursor.execute( + sql.SQL(statement + user_definition + ";").format(sql.Identifier(user)) + ) except psycopg2.Error as e: logger.error(f"Failed to create user: {e}") raise PostgreSQLCreateUserError() @@ -159,9 +171,11 @@ def delete_user(self, user: str) -> None: with self._connect_to_database( database ) as connection, connection.cursor() as cursor: - cursor.execute(sql.SQL("REASSIGN OWNED BY {} TO {};").format( - sql.Identifier(user), sql.Identifier(self.user) - )) + cursor.execute( + sql.SQL("REASSIGN OWNED BY {} TO {};").format( + sql.Identifier(user), sql.Identifier(self.user) + ) + ) cursor.execute(sql.SQL("DROP OWNED BY {};").format(sql.Identifier(user))) # Delete the user. @@ -186,6 +200,26 @@ def get_postgresql_version(self) -> str: logger.error(f"Failed to get PostgreSQL version: {e}") raise PostgreSQLGetPostgreSQLVersionError() + def is_tls_enabled(self, check_current_host: bool = False) -> bool: + """Returns whether TLS is enabled. + + Args: + check_current_host: whether to check the current host + instead of the primary host. + + Returns: + whether TLS is enabled. + """ + try: + with self._connect_to_database( + connect_to_current_host=check_current_host + ) as connection, connection.cursor() as cursor: + cursor.execute("SHOW ssl;") + return "on" in cursor.fetchone()[0] + except psycopg2.Error: + # Connection errors happen when PostgreSQL has not started yet. + return False + def list_users(self) -> Set[str]: """Returns the list of PostgreSQL database users. diff --git a/lib/charms/postgresql_k8s/v0/postgresql_tls.py b/lib/charms/postgresql_k8s/v0/postgresql_tls.py index 5f1a0c2955..0fee4bce3f 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql_tls.py +++ b/lib/charms/postgresql_k8s/v0/postgresql_tls.py @@ -175,7 +175,7 @@ def _get_tls_extensions() -> Optional[List[ExtensionType]]: basic_constraints = x509.BasicConstraints(ca=True, path_length=None) return [basic_constraints] - def get_tls_files(self) -> (Optional[str], Optional[str]): + def get_tls_files(self) -> (Optional[str], Optional[str], Optional[str]): """Prepare TLS files in special PostgreSQL way. PostgreSQL needs three files: diff --git a/src/charm.py b/src/charm.py index 49475de1b0..cb79ff4d97 100755 --- a/src/charm.py +++ b/src/charm.py @@ -130,7 +130,8 @@ def _set_secret(self, scope: str, key: str, value: Optional[str]) -> None: def postgresql(self) -> PostgreSQL: """Returns an instance of the object used to interact with the database.""" return PostgreSQL( - host=self.primary_endpoint, + primary_host=self.primary_endpoint, + current_host=self._unit_ip, user=USER, password=self._get_secret("app", f"{USER}-password"), database="postgres", From 7c1c74ede615328dd2b19853cc65dd34e97a47e8 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 7 Sep 2022 17:24:58 -0300 Subject: [PATCH 22/34] Add TLS implementation --- actions.yaml | 6 +++ src/charm.py | 95 +++++++++++++++++++++++++++-------- src/cluster.py | 48 +++++++++--------- src/constants.py | 3 ++ templates/patroni.yml.j2 | 13 +++-- tests/integration/test_tls.py | 0 tests/unit/test_charm.py | 32 ++++++------ tests/unit/test_cluster.py | 8 +-- 8 files changed, 135 insertions(+), 70 deletions(-) create mode 100644 tests/integration/test_tls.py diff --git a/actions.yaml b/actions.yaml index 54a0da8f89..774eb83722 100644 --- a/actions.yaml +++ b/actions.yaml @@ -22,3 +22,9 @@ set-password: password: type: string description: The password will be auto-generated if this option is not specified. +set-tls-private-key: + description: Set the private key, which will be used for certificate signing requests (CSR). Run for each unit separately. + params: + private-key: + type: string + description: The content of private key for communications with clients. Content will be auto-generated if this option is not specified. diff --git a/src/charm.py b/src/charm.py index cb79ff4d97..efa4910ae7 100755 --- a/src/charm.py +++ b/src/charm.py @@ -15,6 +15,8 @@ PostgreSQLCreateUserError, PostgreSQLUpdateUserPasswordError, ) +from charms.postgresql_k8s.v0.postgresql_tls import PostgreSQLTLS +from charms.rolling_ops.v0.rollingops import RollingOpsManager from ops.charm import ( ActionEvent, CharmBase, @@ -45,6 +47,9 @@ PEER, REPLICATION_PASSWORD_KEY, SYSTEM_USERS, + TLS_CA_FILE, + TLS_CERT_FILE, + TLS_KEY_FILE, USER, USER_PASSWORD_KEY, ) @@ -83,6 +88,10 @@ def __init__(self, *args): self.postgresql_client_relation = PostgreSQLProvider(self) self.legacy_db_relation = DbProvides(self, admin=False) self.legacy_db_admin_relation = DbProvides(self, admin=True) + self.tls = PostgreSQLTLS(self, PEER) + self.restart_manager = RollingOpsManager( + charm=self, relation="restart", callback=self._restart + ) @property def app_peer_data(self) -> Dict: @@ -102,7 +111,7 @@ def unit_peer_data(self) -> Dict: return relation.data[self.unit] - def _get_secret(self, scope: str, key: str) -> Optional[str]: + def get_secret(self, scope: str, key: str) -> Optional[str]: """Get secret from the secret storage.""" if scope == "unit": return self.unit_peer_data.get(key, None) @@ -111,7 +120,7 @@ def _get_secret(self, scope: str, key: str) -> Optional[str]: else: raise RuntimeError("Unknown secret scope.") - def _set_secret(self, scope: str, key: str, value: Optional[str]) -> None: + def set_secret(self, scope: str, key: str, value: Optional[str]) -> None: """Get secret from the secret storage.""" if scope == "unit": if not value: @@ -133,7 +142,7 @@ def postgresql(self) -> PostgreSQL: primary_host=self.primary_endpoint, current_host=self._unit_ip, user=USER, - password=self._get_secret("app", f"{USER}-password"), + password=self.get_secret("app", f"{USER}-password"), database="postgres", ) @@ -155,6 +164,18 @@ def primary_endpoint(self) -> Optional[str]: else: return primary_endpoint + def get_hostname_by_unit(self, unit_name: str) -> str: + """Create a DNS name for a PostgreSQL unit. + + Args: + unit_name: the juju unit name, e.g. "postgresql/1". + + Returns: + A string representing the hostname of the PostgreSQL unit. + """ + unit_id = unit_name.split("/")[1] + return f"{self.app.name}-{unit_id}.{self.app.name}-endpoints" + def _on_get_primary(self, event: ActionEvent) -> None: """Get primary instance.""" try: @@ -196,7 +217,7 @@ def _on_peer_relation_departed(self, event: RelationDepartedEvent) -> None: # Update the list of the current members. self._remove_from_members_ips(member_ip) - self._patroni.update_cluster_members() + self.update_config() if self.primary_endpoint: self.postgresql_client_relation.update_endpoints() @@ -271,7 +292,7 @@ def _on_peer_relation_changed(self, event: RelationChangedEvent): # Update the list of the cluster members in the replicas to make them know each other. try: # Update the members of the cluster in the Patroni configuration on this unit. - self._patroni.update_cluster_members() + self.update_config() except RetryError: self.unit.status = BlockedStatus("failed to update cluster members on member") return @@ -348,7 +369,7 @@ def add_cluster_member(self, member: str) -> None: # Update Patroni configuration file. try: - self._patroni.update_cluster_members() + self.update_config() except RetryError: self.unit.status = BlockedStatus("failed to update cluster members on member") @@ -523,10 +544,10 @@ def _inhibit_default_cluster_creation(self) -> None: def _on_leader_elected(self, event: LeaderElectedEvent) -> None: """Handle the leader-elected event.""" # The leader sets the needed passwords if they weren't set before. - if self._get_secret("app", USER_PASSWORD_KEY) is None: - self._set_secret("app", USER_PASSWORD_KEY, new_password()) - if self._get_secret("app", REPLICATION_PASSWORD_KEY) is None: - self._set_secret("app", REPLICATION_PASSWORD_KEY, new_password()) + if self.get_secret("app", USER_PASSWORD_KEY) is None: + self.set_secret("app", USER_PASSWORD_KEY, new_password()) + if self.get_secret("app", REPLICATION_PASSWORD_KEY) is None: + self.set_secret("app", REPLICATION_PASSWORD_KEY, new_password()) # Update the list of the current PostgreSQL hosts when a new leader is elected. # Add this unit to the list of cluster members @@ -538,7 +559,7 @@ def _on_leader_elected(self, event: LeaderElectedEvent) -> None: for ip in self._get_ips_to_remove(): self._remove_from_members_ips(ip) - self._patroni.update_cluster_members() + self.update_config() # Don't update connection endpoints in the first time this event run for # this application because there are no primary and replicas yet. @@ -635,9 +656,7 @@ def _on_get_password(self, event: ActionEvent) -> None: f" {', '.join(SYSTEM_USERS)} not {username}" ) return - event.set_results( - {f"{username}-password": self._get_secret("app", f"{username}-password")} - ) + event.set_results({f"{username}-password": self.get_secret("app", f"{username}-password")}) def _on_set_password(self, event: ActionEvent) -> None: """Set the password for the specified user.""" @@ -656,7 +675,7 @@ def _on_set_password(self, event: ActionEvent) -> None: password = event.params.get("password", new_password()) - if password == self._get_secret("app", f"{username}-password"): + if password == self.get_secret("app", f"{username}-password"): event.log("The old and new passwords are equal.") event.set_results({f"{username}-password": password}) return @@ -681,12 +700,11 @@ def _on_set_password(self, event: ActionEvent) -> None: return # Update the password in the secret store. - self._set_secret("app", f"{username}-password", password) + self.set_secret("app", f"{username}-password", password) # Update and reload Patroni configuration in this unit to use the new password. # Other units Patroni configuration will be reloaded in the peer relation changed event. - self._patroni.render_patroni_yml_file() - self._patroni.reload_patroni_configuration() + self.update_config() event.set_results({f"{username}-password": password}) @@ -709,7 +727,7 @@ def _get_password(self) -> str: The password from the peer relation or None if the password has not yet been set by the leader. """ - return self._get_secret("app", USER_PASSWORD_KEY) + return self.get_secret("app", USER_PASSWORD_KEY) @property def _replication_password(self) -> str: @@ -719,7 +737,7 @@ def _replication_password(self) -> str: The password from the peer relation or None if the password has not yet been set by the leader. """ - return self._get_secret("app", REPLICATION_PASSWORD_KEY) + return self.get_secret("app", REPLICATION_PASSWORD_KEY) def _install_apt_packages(self, _, packages: List[str]) -> None: """Simple wrapper around 'apt-get install -y. @@ -775,6 +793,43 @@ def _peers(self) -> Relation: """ return self.model.get_relation(PEER) + def push_tls_files_to_workload(self) -> None: + """Uploads TLS files to the workload container.""" + key, ca, cert = self.tls.get_tls_files() + if key is not None: + self._patroni.render_file(f"{self._storage_path}/{TLS_KEY_FILE}", key, 0o600) + if ca is not None: + self._patroni.render_file(f"{self._storage_path}/{TLS_CA_FILE}", ca, 0o600) + if cert is not None: + self._patroni.render_file(f"{self._storage_path}/{TLS_CERT_FILE}", cert, 0o600) + + self.update_config() + + def _restart(self, _) -> None: + """Restart PostgreSQL.""" + try: + self._patroni.restart_postgresql() + except RetryError as e: + logger.error("failed to restart PostgreSQL") + self.unit.status = BlockedStatus(f"failed to restart PostgreSQL with error {e}") + + def update_config(self) -> None: + """Updates Patroni config file based on the existence of the TLS files.""" + enable_tls = all(self.tls.get_tls_files()) + + # Update and reload configuration based on TLS files availability. + self._patroni.render_patroni_yml_file(enable_tls=enable_tls) + if not self._patroni.member_started: + return + + restart_postgresql = enable_tls != self.postgresql.is_tls_enabled() + self._patroni.reload_patroni_configuration() + + # Restart PostgreSQL if TLS configuration has changed + # (so the both old and new connections use the configuration). + if restart_postgresql: + self.on[self.restart_manager.name].acquire_lock.emit() + if __name__ == "__main__": main(PostgresqlOperatorCharm) diff --git a/src/cluster.py b/src/cluster.py index 9c28b19119..b8ed558de3 100644 --- a/src/cluster.py +++ b/src/cluster.py @@ -84,21 +84,18 @@ def __init__( self.superuser_password = superuser_password self.replication_password = replication_password - def bootstrap_cluster(self, replica: bool = False) -> bool: + def bootstrap_cluster(self) -> bool: """Bootstrap a PostgreSQL cluster using Patroni.""" # Render the configuration files and start the cluster. - self.configure_patroni_on_unit(replica) + self.configure_patroni_on_unit() return self.start_patroni() - def configure_patroni_on_unit(self, replica: bool = False): - """Configure Patroni (configuration files and service) on the unit. - - Args: - replica: whether the unit should be configured as a replica - (defaults to False, which configures the unit as a leader) - """ + def configure_patroni_on_unit(self): + """Configure Patroni (configuration files and service) on the unit.""" self._change_owner(self.storage_path) - self.render_patroni_yml_file(replica) + # Avoid rendering the Patroni config file if it was already rendered. + if not os.path.exists(f"{self.storage_path}/patroni.yml"): + self.render_patroni_yml_file() self._render_patroni_service_file() # Reload systemd services before trying to start Patroni. daemon_reload() @@ -221,7 +218,7 @@ def member_started(self) -> bool: return r.json()["state"] == "running" - def _render_file(self, path: str, content: str, mode: int) -> None: + def render_file(self, path: str, content: str, mode: int) -> None: """Write a content rendered from a template to a file. Args: @@ -246,27 +243,31 @@ def _render_patroni_service_file(self) -> None: template = Template(file.read()) # Render the template file with the correct values. rendered = template.render(conf_path=self.storage_path) - self._render_file("/etc/systemd/system/patroni.service", rendered, 0o644) + self.render_file("/etc/systemd/system/patroni.service", rendered, 0o644) - def render_patroni_yml_file(self, replica: bool = False) -> None: - """Render the Patroni configuration file.""" + def render_patroni_yml_file(self, enable_tls: bool = False) -> None: + """Render the Patroni configuration file. + + Args: + enable_tls: whether to enable TLS. + """ # Open the template patroni.yml file. with open("templates/patroni.yml.j2", "r") as file: template = Template(file.read()) # Render the template file with the correct values. rendered = template.render( conf_path=self.storage_path, + enable_tls=enable_tls, member_name=self.member_name, peers_ips=self.peers_ips, scope=self.cluster_name, self_ip=self.unit_ip, - replica=replica, superuser=USER, superuser_password=self.superuser_password, replication_password=self.replication_password, version=self._get_postgresql_version(), ) - self._render_file(f"{self.storage_path}/patroni.yml", rendered, 0o644) + self.render_file(f"{self.storage_path}/patroni.yml", rendered, 0o644) def render_postgresql_conf_file(self) -> None: """Render the PostgreSQL configuration file.""" @@ -281,7 +282,7 @@ def render_postgresql_conf_file(self) -> None: synchronous_standby_names="*", ) self._create_directory(f"{self.storage_path}/conf.d", mode=0o644) - self._render_file(f"{self.storage_path}/conf.d/postgresql-operator.conf", rendered, 0o644) + self.render_file(f"{self.storage_path}/conf.d/postgresql-operator.conf", rendered, 0o644) def start_patroni(self) -> bool: """Start Patroni service using systemd. @@ -317,14 +318,6 @@ def primary_changed(self, old_primary: str) -> bool: primary = self.get_primary() return primary != old_primary - def update_cluster_members(self) -> None: - """Update the list of members of the cluster.""" - # Update the members in the Patroni configuration. - self.render_patroni_yml_file() - - if service_running(PATRONI_SERVICE): - self.reload_patroni_configuration() - def remove_raft_member(self, member_ip: str) -> None: """Remove a member from the raft cluster. @@ -356,3 +349,8 @@ def remove_raft_member(self, member_ip: str) -> None: def reload_patroni_configuration(self): """Reload Patroni configuration after it was changed.""" requests.post(f"http://{self.unit_ip}:8008/reload") + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) + def restart_postgresql(self) -> None: + """Restart PostgreSQL.""" + requests.post(f"http://{self.unit_ip}:8008/restart") diff --git a/src/constants.py b/src/constants.py index 1554176034..59db6f0a38 100644 --- a/src/constants.py +++ b/src/constants.py @@ -11,6 +11,9 @@ ALL_CLIENT_RELATIONS = [DATABASE, LEGACY_DB, LEGACY_DB_ADMIN] REPLICATION_USER = "replication" REPLICATION_PASSWORD_KEY = "replication-password" +TLS_KEY_FILE = "key.pem" +TLS_CA_FILE = "ca.pem" +TLS_CERT_FILE = "cert.pem" USER = "operator" USER_PASSWORD_KEY = "operator-password" # List of system usernames needed for correct work of the charm/workload. diff --git a/templates/patroni.yml.j2 b/templates/patroni.yml.j2 index 0ced378216..0356ef89cc 100644 --- a/templates/patroni.yml.j2 +++ b/templates/patroni.yml.j2 @@ -49,13 +49,20 @@ postgresql: # Path to PostgreSQL binaries used in the database bootstrap process. bin_dir: /usr/lib/postgresql/{{ version }}/bin/ data_dir: {{ conf_path }}/pgdata + {%- if enable_tls %} + parameters: + ssl: on + ssl_ca_file: {{ conf_path }}/ca.pem + ssl_cert_file: {{ conf_path }}/cert.pem + ssl_key_file: {{ conf_path }}/key.pem + {%- endif %} pgpass: /tmp/pgpass pg_hba: - - host replication replication 127.0.0.1/32 md5 - - host all all 0.0.0.0/0 md5 + - {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 md5 + - {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 md5 # Allow replications connections from other cluster members. {%- for peer_ip in peers_ips %} - - host replication replication {{ peer_ip }}/0 md5 + - {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ peer_ip }}/0 md5 {% endfor %} authentication: replication: diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 3e8e429c60..bf97a7470a 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -116,11 +116,9 @@ def test_inhibit_default_cluster_creation(self, _makedirs): "charm.PostgresqlOperatorCharm.primary_endpoint", new_callable=PropertyMock, ) - @patch("charm.Patroni.update_cluster_members") + @patch("charm.PostgresqlOperatorCharm.update_config") @patch_network_get(private_address="1.1.1.1") - def test_on_leader_elected( - self, _update_cluster_members, _primary_endpoint, _update_endpoints - ): + def test_on_leader_elected(self, _update_config, _primary_endpoint, _update_endpoints): # Assert that there is no password in the peer relation. self.assertIsNone(self.charm._peers.data[self.charm.app].get("operator-password", None)) @@ -128,7 +126,7 @@ def test_on_leader_elected( _primary_endpoint.return_value = "1.1.1.1" self.harness.set_leader() password = self.charm._peers.data[self.charm.app].get("operator-password", None) - _update_cluster_members.assert_called_once() + _update_config.assert_called_once() _update_endpoints.assert_not_called() self.assertIsNotNone(password) @@ -156,7 +154,7 @@ def test_on_leader_elected( @patch("charm.PostgreSQLProvider.oversee_users") @patch("charm.PostgresqlOperatorCharm.postgresql") @patch("charm.PostgreSQLProvider.update_endpoints") - @patch("charm.Patroni.update_cluster_members") + @patch("charm.PostgresqlOperatorCharm.update_config") @patch("charm.Patroni.member_started") @patch("charm.Patroni.bootstrap_cluster") @patch("charm.PostgresqlOperatorCharm._replication_password") @@ -265,9 +263,8 @@ def test_on_get_password(self): ) @patch_network_get(private_address="1.1.1.1") - @patch("charm.Patroni.reload_patroni_configuration") - @patch("charm.Patroni.render_patroni_yml_file") - @patch("charm.PostgresqlOperatorCharm._set_secret") + @patch("charm.PostgresqlOperatorCharm.update_config") + @patch("charm.PostgresqlOperatorCharm.set_secret") @patch("charm.PostgresqlOperatorCharm.postgresql") @patch("charm.Patroni.are_all_members_ready") @patch("charm.PostgresqlOperatorCharm._on_leader_elected") @@ -277,8 +274,7 @@ def test_on_set_password( _are_all_members_ready, _postgresql, _set_secret, - _render_patroni_yml_file, - _reload_patroni_configuration, + __, ): # Create a mock event. mock_event = MagicMock(params={}) @@ -391,22 +387,22 @@ def test_install_pip_packages(self, _call): @patch_network_get(private_address="1.1.1.1") @patch("charm.PostgresqlOperatorCharm._on_leader_elected") - def test_get_secret(self, _): + def testget_secret(self, _): self.harness.set_leader() # Test application scope. - assert self.charm._get_secret("app", "password") is None + assert self.charm.get_secret("app", "password") is None self.harness.update_relation_data( self.rel_id, self.charm.app.name, {"password": "test-password"} ) - assert self.charm._get_secret("app", "password") == "test-password" + assert self.charm.get_secret("app", "password") == "test-password" # Test unit scope. - assert self.charm._get_secret("unit", "password") is None + assert self.charm.get_secret("unit", "password") is None self.harness.update_relation_data( self.rel_id, self.charm.unit.name, {"password": "test-password"} ) - assert self.charm._get_secret("unit", "password") == "test-password" + assert self.charm.get_secret("unit", "password") == "test-password" @patch_network_get(private_address="1.1.1.1") @patch("charm.PostgresqlOperatorCharm._on_leader_elected") @@ -415,7 +411,7 @@ def test_set_secret(self, _): # Test application scope. assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.app.name) - self.charm._set_secret("app", "password", "test-password") + self.charm.set_secret("app", "password", "test-password") assert ( self.harness.get_relation_data(self.rel_id, self.charm.app.name)["password"] == "test-password" @@ -423,7 +419,7 @@ def test_set_secret(self, _): # Test unit scope. assert "password" not in self.harness.get_relation_data(self.rel_id, self.charm.unit.name) - self.charm._set_secret("unit", "password", "test-password") + self.charm.set_secret("unit", "password", "test-password") assert ( self.harness.get_relation_data(self.rel_id, self.charm.unit.name)["password"] == "test-password" diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 6947cae694..86a6141041 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -55,7 +55,7 @@ def test_render_file(self, _temp_file, _pwnam, _chown, _chmod): _pwnam.return_value.pw_uid = 35 _pwnam.return_value.pw_gid = 35 # Call the method using a temporary configuration file. - self.patroni._render_file(filename, "rendered-content", 0o640) + self.patroni.render_file(filename, "rendered-content", 0o640) # Check the rendered file is opened with "w+" mode. self.assertEqual(mock.call_args_list[0][0], (filename, "w+")) @@ -66,7 +66,7 @@ def test_render_file(self, _temp_file, _pwnam, _chown, _chmod): # Ensure the file is chown'd correctly. _chown.assert_called_with(filename, uid=35, gid=35) - @patch("charm.Patroni._render_file") + @patch("charm.Patroni.render_file") @patch("charm.Patroni._create_directory") def test_render_patroni_service_file(self, _, _render_file): # Get the expected content from a file. @@ -92,7 +92,7 @@ def test_render_patroni_service_file(self, _, _render_file): 0o644, ) - @patch("charm.Patroni._render_file") + @patch("charm.Patroni.render_file") @patch("charm.Patroni._create_directory") def test_render_patroni_yml_file(self, _, _render_file): # Define variables to render in the template. @@ -134,7 +134,7 @@ def test_render_patroni_yml_file(self, _, _render_file): 0o644, ) - @patch("charm.Patroni._render_file") + @patch("charm.Patroni.render_file") @patch("charm.Patroni._create_directory") def test_render_postgresql_conf_file(self, _, _render_file): # Get the expected content from a file. From 0bce5702e7bbde18c3a996859c93f342202aaa7b Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 7 Sep 2022 17:26:55 -0300 Subject: [PATCH 23/34] Delete file --- tests/integration/test_tls.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tests/integration/test_tls.py diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py deleted file mode 100644 index e69de29bb2..0000000000 From 5121002842cba9f81095e13acfb921eee8985735 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 7 Sep 2022 17:29:09 -0300 Subject: [PATCH 24/34] Add integration test --- .github/workflows/ci.yaml | 13 ++++++++++ .github/workflows/release.yaml | 14 +++++++++++ pyproject.toml | 1 + tests/integration/helpers.py | 34 ++++++++++++++++++++++++- tests/integration/test_tls.py | 45 ++++++++++++++++++++++++++++++++++ tox.ini | 19 ++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_tls.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ab8c30e558..ec0d39c6ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -91,3 +91,16 @@ jobs: provider: lxd - name: Run integration tests run: tox -e password-rotation-integration + + integration-test-lxd-tls: + name: Integration tests for TLS (lxd) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e tls-integration diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9c00b80780..d943a47c3d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -107,6 +107,19 @@ jobs: - name: Run integration tests run: tox -e password-rotation-integration + integration-test-tls: + name: Integration tests for TLS + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: lxd + - name: Run integration tests + run: tox -e tls-integration + release-to-charmhub: name: Release to CharmHub needs: @@ -118,6 +131,7 @@ jobs: - integration-test-db-relation - integration-test-db-admin-relation - integration-test-password-rotation + - integration-test-tls runs-on: ubuntu-latest steps: - name: Checkout diff --git a/pyproject.toml b/pyproject.toml index d873e96cb4..728e95c86d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ markers = [ "db_relation_tests", "db_admin_relation_tests", "password_rotation_tests", + "tls_tests", ] # Formatting tools configuration diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 013c315cd4..fc20f27b28 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -14,6 +14,7 @@ from juju.unit import Unit from pytest_operator.plugin import OpsTest from tenacity import ( + RetryError, Retrying, retry, retry_if_result, @@ -308,6 +309,7 @@ async def execute_query_on_unit( password: str, query: str, database: str = "postgres", + sslmode: str = None, ): """Execute given PostgreSQL query on a unit. @@ -316,12 +318,15 @@ async def execute_query_on_unit( password: The PostgreSQL superuser password. query: Query to execute. database: Optional database to connect to (defaults to postgres database). + sslmode: Optional ssl mode to use (defaults to None). Returns: A list of rows that were potentially returned from the query. """ + extra_connection_parameters = f" sslmode={sslmode}" if sslmode is not None else "" with psycopg2.connect( - f"dbname='{database}' user='operator' host='{unit_address}' password='{password}' connect_timeout=10" + f"dbname='{database}' user='operator' host='{unit_address}'" + f"password='{password}' connect_timeout=10{extra_connection_parameters}" ) as connection, connection.cursor() as cursor: cursor.execute(query) output = list(itertools.chain(*cursor.fetchall())) @@ -420,6 +425,33 @@ def get_unit_address(ops_test: OpsTest, unit_name: str) -> str: return ops_test.model.units.get(unit_name).public_address +async def is_tls_enabled(ops_test: OpsTest, unit_name: str) -> bool: + """Returns whether TLS is enabled on the specific PostgreSQL instance. + + Args: + ops_test: The ops test framework instance. + unit_name: The name of the unit of the PostgreSQL instance. + + Returns: + Whether TLS is enabled. + """ + unit_address = get_unit_address(ops_test, unit_name) + password = await get_password(ops_test, unit_name) + try: + for attempt in Retrying( + stop=stop_after_attempt(10), wait=wait_exponential(multiplier=1, min=2, max=30) + ): + with attempt: + output = await execute_query_on_unit( + unit_address, password, "SHOW ssl;", sslmode="require" + ) + if "on" not in output: + raise ValueError(f"TLS is not enabled on {unit_name}") + except RetryError: + return False + return "on" in output + + async def scale_application(ops_test: OpsTest, application_name: str, count: int) -> None: """Scale a given application to a specific unit count. diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py new file mode 100644 index 0000000000..ae48dfe11b --- /dev/null +++ b/tests/integration/test_tls.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import pytest as pytest +from pytest_operator.plugin import OpsTest + +from tests.helpers import METADATA +from tests.integration.helpers import DATABASE_APP_NAME, is_tls_enabled + +APP_NAME = METADATA["name"] +TLS_CERTIFICATES_APP_NAME = "tls-certificates-operator" + + +@pytest.mark.abort_on_fail +@pytest.mark.tls_tests +@pytest.mark.skip_if_deployed +async def test_deploy_active(ops_test: OpsTest): + """Build the charm and deploy it.""" + charm = await ops_test.build_charm(".") + async with ops_test.fast_forward(): + await ops_test.model.deploy( + charm, resources={"patroni": "patroni.tar.gz"}, application_name=APP_NAME, num_units=3 + ) + await ops_test.juju("attach-resource", APP_NAME, "patroni=patroni.tar.gz") + await ops_test.model.wait_for_idle(apps=[APP_NAME], status="active", timeout=1000) + + +@pytest.mark.tls_tests +async def test_tls_enabled(ops_test: OpsTest) -> None: + """Test that TLS is enabled when relating to the TLS Certificates Operator.""" + async with ops_test.fast_forward(): + # Deploy TLS Certificates operator. + config = {"generate-self-signed-certificates": "true", "ca-common-name": "Test CA"} + await ops_test.model.deploy(TLS_CERTIFICATES_APP_NAME, channel="edge", config=config) + await ops_test.model.wait_for_idle( + apps=[TLS_CERTIFICATES_APP_NAME], status="active", timeout=1000 + ) + + # Relate it to the PostgreSQL to enable TLS. + await ops_test.model.relate(DATABASE_APP_NAME, TLS_CERTIFICATES_APP_NAME) + await ops_test.model.wait_for_idle(status="active", timeout=1000) + + # Wait for all units enabling TLS. + for unit in ops_test.model.applications[DATABASE_APP_NAME].units: + assert await is_tls_enabled(ops_test, unit.name) diff --git a/tox.ini b/tox.ini index c1d1bd1f97..b7185bbf78 100644 --- a/tox.ini +++ b/tox.ini @@ -160,6 +160,25 @@ commands = whitelist_externals = sh +[testenv:tls-integration] +description = Run TLS integration tests +deps = + pytest + juju==2.9.11 # juju 3.0.0 has issues with retrieving action results + landscape-api-py3 + mailmanclient + pytest-operator + psycopg2-binary + -r{toxinidir}/requirements.txt +commands = + # Download patroni resource to use in the charm deployment. + sh -c 'stat patroni.tar.gz > /dev/null 2>&1 || curl "https://github.com/zalando/patroni/archive/refs/tags/v2.1.3.tar.gz" -L -s > patroni.tar.gz' + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} -m tls_tests + # Remove the downloaded resource. + sh -c 'rm -f patroni.tar.gz' +whitelist_externals = + sh + [testenv:integration] description = Run all integration tests deps = From 9d761b7fbe4982710f4e43436a526908152f9f65 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 8 Sep 2022 13:23:27 -0300 Subject: [PATCH 25/34] Update library --- .../v1/tls_certificates.py | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/lib/charms/tls_certificates_interface/v1/tls_certificates.py b/lib/charms/tls_certificates_interface/v1/tls_certificates.py index 60432bb35c..7893ff9398 100644 --- a/lib/charms/tls_certificates_interface/v1/tls_certificates.py +++ b/lib/charms/tls_certificates_interface/v1/tls_certificates.py @@ -63,7 +63,7 @@ def __init__(self, *args): self.certificates.on.certificate_request, self._on_certificate_request ) self.framework.observe( - self.certificates.on.certificate_revoked, self._on_certificate_revoked + self.certificates.on.certificate_revoked, self._on_certificate_revocation_request ) self.framework.observe(self.on.install, self._on_install) @@ -103,11 +103,11 @@ def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> Non certificate=certificate, certificate_signing_request=event.certificate_signing_request, ca=ca_certificate, - chain=ca_certificate, + chain=[ca_certificate, certificate], relation_id=event.relation_id, ) - def _on_certificate_revoked(self, event: CertificateRevocationRequestEvent) -> None: + def _on_certificate_revocation_request(self, event: CertificateRevocationRequestEvent) -> None: # Do what you want to do with this information pass @@ -123,11 +123,9 @@ def _on_certificate_revoked(self, event: CertificateRevocationRequestEvent) -> N Example: ```python - from charms.tls_certificates_interface.v1.tls_certificates import ( CertificateAvailableEvent, CertificateExpiringEvent, - CertificateRevokedEvent, TLSCertificatesRequiresV1, generate_csr, generate_private_key, @@ -242,7 +240,7 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 5 +LIBPATCH = 6 REQUIRER_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-04/schema#", @@ -287,7 +285,9 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: "certificates": [ { "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 - "chain": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": [ + "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 + ], "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 } @@ -312,7 +312,14 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: "type": "string", }, "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, - "chain": {"$id": "#/properties/certificates/items/chain", "type": "string"}, + "chain": { + "$id": "#/properties/certificates/items/chain", + "type": "array", + "items": { + "type": "string", + "$id": "#/properties/certificates/items/chain/items", + }, + }, }, "additionalProperties": True, }, @@ -335,7 +342,7 @@ def __init__( certificate: str, certificate_signing_request: str, ca: str, - chain: str, + chain: List[str], ): super().__init__(handle) self.certificate = certificate @@ -457,22 +464,6 @@ def restore(self, snapshot: dict): self.chain = snapshot["chain"] -class CertificateRevokedEvent(EventBase): - """Charm Event triggered when a TLS certificate is revoked.""" - - def __init__(self, handle, certificate_signing_request: str): - super().__init__(handle) - self.certificate_signing_request = certificate_signing_request - - def snapshot(self) -> dict: - """Returns snapshot.""" - return {"certificate_signing_request": self.certificate_signing_request} - - def restore(self, snapshot): - """Restores snapshot.""" - self.certificate_signing_request = snapshot["certificate_signing_request"] - - def _load_relation_data(raw_relation_data: dict) -> dict: """Loads relation data from the relation data bag. @@ -742,7 +733,7 @@ def __init__(self, charm: CharmBase, relationship_name: str): self.relationship_name = relationship_name @property - def _provider_certificates(self) -> List[Dict[str, str]]: + def _provider_certificates(self) -> List[Dict]: """Returns list of provider CSR's from relation data.""" relation = self.model.get_relation(self.relationship_name) if not relation: @@ -759,7 +750,7 @@ def _requirer_csrs(self, unit) -> List[Dict[str, str]]: return requirer_relation_data.get("certificate_signing_requests", []) def _add_certificate( - self, certificate: str, certificate_signing_request: str, ca: str, chain: str + self, certificate: str, certificate_signing_request: str, ca: str, chain: List[str] ) -> None: """Adds certificate to relation data. @@ -767,7 +758,7 @@ def _add_certificate( certificate (str): Certificate certificate_signing_request (str): Certificate Signing Request ca (str): CA Certificate - chain (str): CA Chain + chain (list): CA Chain Returns: None @@ -847,7 +838,7 @@ def set_relation_certificate( certificate: str, certificate_signing_request: str, ca: str, - chain: str, + chain: List[str], relation_id: int, ) -> None: """Adds certificates to relation data. @@ -856,7 +847,7 @@ def set_relation_certificate( certificate (str): Certificate certificate_signing_request (str): Certificate signing request ca (str): CA Certificate - chain (str): CA Chain certificate + chain (list): CA Chain relation_id (int): Juju relation ID Returns: @@ -875,7 +866,7 @@ def set_relation_certificate( certificate=certificate.strip(), certificate_signing_request=certificate_signing_request.strip(), ca=ca.strip(), - chain=chain.strip(), + chain=[cert.strip() for cert in chain], ) def remove_certificate(self, certificate: str) -> None: From 00bd1e806811ddd5cdd041ff75e59d48e1e43680 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 8 Sep 2022 13:25:48 -0300 Subject: [PATCH 26/34] Fix PostgreSQL library --- lib/charms/postgresql_k8s/v0/postgresql_tls.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/charms/postgresql_k8s/v0/postgresql_tls.py b/lib/charms/postgresql_k8s/v0/postgresql_tls.py index 0fee4bce3f..59544f1b27 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql_tls.py +++ b/lib/charms/postgresql_k8s/v0/postgresql_tls.py @@ -125,7 +125,9 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: logger.error("An unknown certificate expiring.") return - self.charm.set_secret(SCOPE, "chain", event.chain) + self.charm.set_secret( + SCOPE, "chain", "\n".join(event.chain) if event.chain is not None else None + ) self.charm.set_secret(SCOPE, "cert", event.certificate) self.charm.set_secret(SCOPE, "ca", event.ca) @@ -175,7 +177,7 @@ def _get_tls_extensions() -> Optional[List[ExtensionType]]: basic_constraints = x509.BasicConstraints(ca=True, path_length=None) return [basic_constraints] - def get_tls_files(self) -> (Optional[str], Optional[str], Optional[str]): + def get_tls_files(self) -> (Optional[str], Optional[str]): """Prepare TLS files in special PostgreSQL way. PostgreSQL needs three files: From 30a14840f82edb3c419b0a1c95dff0362e95a21f Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 9 Sep 2022 15:50:44 -0300 Subject: [PATCH 27/34] Add relation broken test --- tests/integration/helpers.py | 19 +++++++++++++------ tests/integration/test_tls.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index fc20f27b28..fabb68f7eb 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -425,15 +425,16 @@ def get_unit_address(ops_test: OpsTest, unit_name: str) -> str: return ops_test.model.units.get(unit_name).public_address -async def is_tls_enabled(ops_test: OpsTest, unit_name: str) -> bool: +async def check_tls(ops_test: OpsTest, unit_name: str, enabled: bool) -> bool: """Returns whether TLS is enabled on the specific PostgreSQL instance. Args: ops_test: The ops test framework instance. unit_name: The name of the unit of the PostgreSQL instance. + enabled: check if TLS is enabled/disabled Returns: - Whether TLS is enabled. + Whether TLS is enabled/disabled. """ unit_address = get_unit_address(ops_test, unit_name) password = await get_password(ops_test, unit_name) @@ -443,13 +444,19 @@ async def is_tls_enabled(ops_test: OpsTest, unit_name: str) -> bool: ): with attempt: output = await execute_query_on_unit( - unit_address, password, "SHOW ssl;", sslmode="require" + unit_address, + password, + "SHOW ssl;", + sslmode="require" if enabled else "disable", ) - if "on" not in output: - raise ValueError(f"TLS is not enabled on {unit_name}") + tls_enabled = "on" in output + if enabled != tls_enabled: + raise ValueError( + f"TLS is{' not' if not tls_enabled else ''} enabled on {unit_name}" + ) + return True except RetryError: return False - return "on" in output async def scale_application(ops_test: OpsTest, application_name: str, count: int) -> None: diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py index ae48dfe11b..7e2ed47e68 100644 --- a/tests/integration/test_tls.py +++ b/tests/integration/test_tls.py @@ -5,7 +5,7 @@ from pytest_operator.plugin import OpsTest from tests.helpers import METADATA -from tests.integration.helpers import DATABASE_APP_NAME, is_tls_enabled +from tests.integration.helpers import DATABASE_APP_NAME, check_tls APP_NAME = METADATA["name"] TLS_CERTIFICATES_APP_NAME = "tls-certificates-operator" @@ -42,4 +42,14 @@ async def test_tls_enabled(ops_test: OpsTest) -> None: # Wait for all units enabling TLS. for unit in ops_test.model.applications[DATABASE_APP_NAME].units: - assert await is_tls_enabled(ops_test, unit.name) + assert await check_tls(ops_test, unit.name, enabled=True) + + # Remove the relation. + await ops_test.model.applications[DATABASE_APP_NAME].remove_relation( + f"{DATABASE_APP_NAME}:certificates", f"{TLS_CERTIFICATES_APP_NAME}:certificates" + ) + await ops_test.model.wait_for_idle(apps=[DATABASE_APP_NAME], status="active", timeout=1000) + + # Wait for all units disabling TLS. + for unit in ops_test.model.applications[DATABASE_APP_NAME].units: + assert await check_tls(ops_test, unit.name, enabled=False) From 91a1f269bce609d9971b454099fca099c22aafbb Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Mon, 12 Sep 2022 10:31:00 -0300 Subject: [PATCH 28/34] Add jsonschema as a binary dependency --- charmcraft.yaml | 2 +- requirements.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/charmcraft.yaml b/charmcraft.yaml index 2d17c43ed0..32279a26f2 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -16,4 +16,4 @@ parts: - libssl-dev - rustc - cargo - charm-binary-python-packages: [psycopg2-binary==2.9.3] + charm-binary-python-packages: [jsonschema, psycopg2-binary==2.9.3] diff --git a/requirements.txt b/requirements.txt index 0241ab4e8a..476e0c1539 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ cryptography==37.0.4 -jsonschema==4.14.0 ops==1.5.0 pgconnstr==1.0.1 requests==2.28.1 From 154df2552cdc4dea40ea6e946cda85decc05dac5 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Mon, 12 Sep 2022 10:56:36 -0300 Subject: [PATCH 29/34] Change hostname to unit ip --- src/charm.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/charm.py b/src/charm.py index efa4910ae7..6ab1cf9276 100755 --- a/src/charm.py +++ b/src/charm.py @@ -164,17 +164,15 @@ def primary_endpoint(self) -> Optional[str]: else: return primary_endpoint - def get_hostname_by_unit(self, unit_name: str) -> str: + def get_hostname_by_unit(self, _) -> str: """Create a DNS name for a PostgreSQL unit. - Args: - unit_name: the juju unit name, e.g. "postgresql/1". - Returns: A string representing the hostname of the PostgreSQL unit. """ - unit_id = unit_name.split("/")[1] - return f"{self.app.name}-{unit_id}.{self.app.name}-endpoints" + # For now, as there is no DNS hostnames on VMs, and it would also depend on + # the underlying provider (LXD, MAAS, etc.), the unit IP is returned. + return self._unit_ip def _on_get_primary(self, event: ActionEvent) -> None: """Get primary instance.""" From 24ac60e9a0b0d6668496ee0d063d1134b440a4d4 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Mon, 12 Sep 2022 11:41:58 -0300 Subject: [PATCH 30/34] Add unit test dependency --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index c1d1bd1f97..0e06f764ff 100644 --- a/tox.ini +++ b/tox.ini @@ -54,6 +54,7 @@ commands = [testenv:unit] description = Run unit tests deps = + jsonschema psycopg2-binary pytest coverage[toml] From d9ec362a09bd56acc714823882f180751685579c Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Mon, 12 Sep 2022 16:52:43 -0300 Subject: [PATCH 31/34] Add workaround for Patroni REST API TLS --- src/charm.py | 2 +- src/cluster.py | 72 ++++++++++++++++++++++++++++++++------ templates/patroni.yml.j2 | 12 +++++++ tests/unit/test_cluster.py | 1 + 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/charm.py b/src/charm.py index 6ab1cf9276..1885a2a827 100755 --- a/src/charm.py +++ b/src/charm.py @@ -821,7 +821,7 @@ def update_config(self) -> None: return restart_postgresql = enable_tls != self.postgresql.is_tls_enabled() - self._patroni.reload_patroni_configuration() + self._patroni.reload_patroni_configuration(restart_postgresql) # Restart PostgreSQL if TLS configuration has changed # (so the both old and new connections use the configuration). diff --git a/src/cluster.py b/src/cluster.py index b8ed558de3..6722e30cd5 100644 --- a/src/cluster.py +++ b/src/cluster.py @@ -28,7 +28,7 @@ wait_fixed, ) -from constants import USER +from constants import TLS_CA_FILE, USER logger = logging.getLogger(__name__) @@ -83,6 +83,52 @@ def __init__( self.peers_ips = peers_ips self.superuser_password = superuser_password self.replication_password = replication_password + # Variable mapping to requests library verify parameter. + self.verify = f"{self.storage_path}/{TLS_CA_FILE}" if self._tls_enabled else True + + @property + def _tls_enabled(self) -> bool: + # return False + def demote(user_uid, user_gid): + def result(): + os.setgid(user_gid) + os.setuid(user_uid) + + return result + + pw_record = pwd.getpwnam("postgres") + user_uid = pw_record.pw_uid + user_gid = pw_record.pw_gid + + try: + env = dict(os.environ, PGPASSWORD=self.superuser_password) + ssl_query_result = subprocess.check_output( + [ + "patronictl", + "-c", + f"{self.storage_path}/patroni.yml", + "query", + self.cluster_name, + "--command", + "SHOW ssl;", + "--dbname", + "postgres", + "--username", + USER, + ], + env=env, + preexec_fn=demote(user_uid, user_gid), + timeout=10, + ).decode("UTF-8") + # logger.warning(ssl_query_result) + return "on" in ssl_query_result + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + return False + + @property + def _patroni_url(self) -> str: + """Patroni REST API URL.""" + return f"{'https' if self._tls_enabled else 'http'}://{self.unit_ip}:8008" def bootstrap_cluster(self) -> bool: """Bootstrap a PostgreSQL cluster using Patroni.""" @@ -117,7 +163,7 @@ def _change_owner(self, path: str) -> None: def cluster_members(self) -> set: """Get the current cluster members.""" # Request info from cluster endpoint (which returns all members of the cluster). - cluster_status = requests.get(f"http://{self.unit_ip}:8008/cluster") + cluster_status = requests.get(f"{self._patroni_url}/cluster", verify=self.verify) return set([member["name"] for member in cluster_status.json()["members"]]) def _create_directory(self, path: str, mode: int) -> None: @@ -150,7 +196,7 @@ def get_member_ip(self, member_name: str) -> str: """ ip = None # Request info from cluster endpoint (which returns all members of the cluster). - cluster_status = requests.get(f"http://{self.unit_ip}:8008/cluster") + cluster_status = requests.get(f"{self._patroni_url}/cluster", verify=self.verify) for member in cluster_status.json()["members"]: if member["name"] == member_name: ip = member["host"] @@ -168,7 +214,7 @@ def get_primary(self, unit_name_pattern=False) -> str: """ primary = None # Request info from cluster endpoint (which returns all members of the cluster). - cluster_status = requests.get(f"http://{self.unit_ip}:8008/cluster") + cluster_status = requests.get(f"{self._patroni_url}/cluster", verify=self.verify) for member in cluster_status.json()["members"]: if member["role"] == "leader": primary = member["name"] @@ -190,7 +236,9 @@ def are_all_members_ready(self) -> bool: try: for attempt in Retrying(stop=stop_after_delay(10), wait=wait_fixed(3)): with attempt: - cluster_status = requests.get(f"http://{self.unit_ip}:8008/cluster") + cluster_status = requests.get( + f"{self._patroni_url}/cluster", verify=self.verify + ) except RetryError: return False @@ -212,7 +260,7 @@ def member_started(self) -> bool: try: for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): with attempt: - r = requests.get(f"http://{self.unit_ip}:8008/health") + r = requests.get(f"{self._patroni_url}/health", verify=self.verify) except RetryError: return False @@ -300,8 +348,9 @@ def switchover(self) -> None: with attempt: current_primary = self.get_primary() r = requests.post( - f"http://{self.unit_ip}:8008/switchover", + f"{self._patroni_url}/switchover", json={"leader": current_primary}, + verify=self.verify, ) # Check whether the switchover was unsuccessful. @@ -346,11 +395,14 @@ def remove_raft_member(self, member_ip: str) -> None: raise RemoveRaftMemberFailedError() @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) - def reload_patroni_configuration(self): + def reload_patroni_configuration(self, restart_postgresql: bool = False): """Reload Patroni configuration after it was changed.""" - requests.post(f"http://{self.unit_ip}:8008/reload") + url = self._patroni_url + if restart_postgresql: + url.replace("https", "http") + requests.post(f"{url}/reload", verify=self.verify) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) def restart_postgresql(self) -> None: """Restart PostgreSQL.""" - requests.post(f"http://{self.unit_ip}:8008/restart") + requests.post(f"{self._patroni_url}/restart", verify=self.verify) diff --git a/templates/patroni.yml.j2 b/templates/patroni.yml.j2 index 0356ef89cc..f5ba233894 100644 --- a/templates/patroni.yml.j2 +++ b/templates/patroni.yml.j2 @@ -17,6 +17,18 @@ log: restapi: listen: '{{ self_ip }}:8008' connect_address: '{{ self_ip }}:8008' + {%- if enable_tls %} + cafile: {{ conf_path }}/ca.pem + certfile: {{ conf_path }}/cert.pem + keyfile: {{ conf_path }}/key.pem + {%- endif %} + +{%- if enable_tls %} +ctl: + cacert: {{ conf_path }}/ca.pem + certfile: {{ conf_path }}/cert.pem + keyfile: {{ conf_path }}/key.pem +{%- endif %} raft: data_dir: {{ conf_path }}/raft diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index 86a6141041..73a0446b38 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -27,6 +27,7 @@ def setUp(self): peers_ips, "fake-superuser-password", "fake-replication-password", + False, ) @patch("charms.operator_libs_linux.v0.apt.DebianPackage.from_system") From bdca5d4c44c610cdfcc18afca3efd5a64d12b981 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Mon, 12 Sep 2022 23:02:27 -0300 Subject: [PATCH 32/34] Improve TLS status retrieval --- src/charm.py | 4 +++- src/cluster.py | 55 +++++++++----------------------------------------- 2 files changed, 12 insertions(+), 47 deletions(-) diff --git a/src/charm.py b/src/charm.py index 1885a2a827..c5ebd0d7fe 100755 --- a/src/charm.py +++ b/src/charm.py @@ -410,6 +410,7 @@ def _patroni(self) -> Patroni: self._peer_members_ips, self._get_password(), self._replication_password, + bool(self.unit_peer_data.get("tls")), ) @property @@ -821,7 +822,8 @@ def update_config(self) -> None: return restart_postgresql = enable_tls != self.postgresql.is_tls_enabled() - self._patroni.reload_patroni_configuration(restart_postgresql) + self._patroni.reload_patroni_configuration() + self.unit_peer_data.update({"tls": "enabled" if enable_tls else ""}) # Restart PostgreSQL if TLS configuration has changed # (so the both old and new connections use the configuration). diff --git a/src/cluster.py b/src/cluster.py index 6722e30cd5..a793c2b7ab 100644 --- a/src/cluster.py +++ b/src/cluster.py @@ -62,6 +62,7 @@ def __init__( peers_ips: Set[str], superuser_password: str, replication_password: str, + tls_enabled: bool, ): """Initialize the Patroni class. @@ -74,6 +75,7 @@ def __init__( planned_units: number of units planned for the cluster superuser_password: password for the operator user replication_password: password for the user used in the replication + tls_enabled: whether TLS is enabled """ self.unit_ip = unit_ip self.storage_path = storage_path @@ -83,52 +85,16 @@ def __init__( self.peers_ips = peers_ips self.superuser_password = superuser_password self.replication_password = replication_password + self.tls_enabled = tls_enabled # Variable mapping to requests library verify parameter. - self.verify = f"{self.storage_path}/{TLS_CA_FILE}" if self._tls_enabled else True - - @property - def _tls_enabled(self) -> bool: - # return False - def demote(user_uid, user_gid): - def result(): - os.setgid(user_gid) - os.setuid(user_uid) - - return result - - pw_record = pwd.getpwnam("postgres") - user_uid = pw_record.pw_uid - user_gid = pw_record.pw_gid - - try: - env = dict(os.environ, PGPASSWORD=self.superuser_password) - ssl_query_result = subprocess.check_output( - [ - "patronictl", - "-c", - f"{self.storage_path}/patroni.yml", - "query", - self.cluster_name, - "--command", - "SHOW ssl;", - "--dbname", - "postgres", - "--username", - USER, - ], - env=env, - preexec_fn=demote(user_uid, user_gid), - timeout=10, - ).decode("UTF-8") - # logger.warning(ssl_query_result) - return "on" in ssl_query_result - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - return False + # The CA bundle file is used to validate the server certificate when + # TLS is enabled, otherwise True is set because it's the default value. + self.verify = f"{self.storage_path}/{TLS_CA_FILE}" if tls_enabled else True @property def _patroni_url(self) -> str: """Patroni REST API URL.""" - return f"{'https' if self._tls_enabled else 'http'}://{self.unit_ip}:8008" + return f"{'https' if self.tls_enabled else 'http'}://{self.unit_ip}:8008" def bootstrap_cluster(self) -> bool: """Bootstrap a PostgreSQL cluster using Patroni.""" @@ -395,12 +361,9 @@ def remove_raft_member(self, member_ip: str) -> None: raise RemoveRaftMemberFailedError() @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) - def reload_patroni_configuration(self, restart_postgresql: bool = False): + def reload_patroni_configuration(self): """Reload Patroni configuration after it was changed.""" - url = self._patroni_url - if restart_postgresql: - url.replace("https", "http") - requests.post(f"{url}/reload", verify=self.verify) + requests.post(f"{self._patroni_url}/reload", verify=self.verify) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) def restart_postgresql(self) -> None: From 7c860b76b9acf71b5309fdc9f744219c19944cf4 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 20 Sep 2022 15:39:22 -0300 Subject: [PATCH 33/34] Readd checks --- src/charm.py | 8 ++--- src/cluster.py | 10 ++++++ tests/integration/helpers.py | 62 +++++++++++++++++++++++++++++++++++ tests/integration/test_tls.py | 8 ++++- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/charm.py b/src/charm.py index 10ad8718d1..965ed1ab82 100755 --- a/src/charm.py +++ b/src/charm.py @@ -821,12 +821,10 @@ def push_tls_files_to_workload(self) -> None: self.update_config() def _restart(self, _) -> None: - """Restart PostgreSQL.""" - try: - self._patroni.restart_postgresql() - except RetryError as e: + """Restart Patroni and PostgreSQL.""" + if not self._patroni.restart_patroni(): logger.exception("failed to restart PostgreSQL") - self.unit.status = BlockedStatus(f"failed to restart PostgreSQL with error {e}") + self.unit.status = BlockedStatus("failed to restart Patroni and PostgreSQL") def update_config(self) -> None: """Updates Patroni config file based on the existence of the TLS files.""" diff --git a/src/cluster.py b/src/cluster.py index a793c2b7ab..e8f4891187 100644 --- a/src/cluster.py +++ b/src/cluster.py @@ -13,6 +13,7 @@ from charms.operator_libs_linux.v0.apt import DebianPackage from charms.operator_libs_linux.v1.systemd import ( daemon_reload, + service_restart, service_running, service_start, ) @@ -365,6 +366,15 @@ def reload_patroni_configuration(self): """Reload Patroni configuration after it was changed.""" requests.post(f"{self._patroni_url}/reload", verify=self.verify) + def restart_patroni(self) -> bool: + """Restart Patroni. + + Returns: + Whether the service restarted successfully. + """ + service_restart(PATRONI_SERVICE) + return service_running(PATRONI_SERVICE) + @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) def restart_postgresql(self) -> None: """Restart PostgreSQL.""" diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 5f02e432b9..2204d8471e 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -2,6 +2,7 @@ # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. import itertools +import json import tempfile import zipfile from datetime import datetime @@ -412,6 +413,32 @@ async def get_primary(ops_test: OpsTest, unit_name: str) -> str: return action.results["primary"] +async def get_tls_ca( + ops_test: OpsTest, + unit_name: str, +) -> str: + """Returns the TLS CA used by the unit. + + Args: + ops_test: The ops test framework instance + unit_name: The name of the unit + + Returns: + TLS CA or an empty string if there is no CA. + """ + raw_data = (await ops_test.juju("show-unit", unit_name))[1] + if not raw_data: + raise ValueError(f"no unit info could be grabbed for {unit_name}") + data = yaml.safe_load(raw_data) + # Filter the data based on the relation name. + relation_data = [ + v for v in data[unit_name]["relation-info"] if v["endpoint"] == "certificates" + ] + if len(relation_data) == 0: + return "" + return json.loads(relation_data[0]["application-data"]["certificates"])[0].get("ca") + + def get_unit_address(ops_test: OpsTest, unit_name: str) -> str: """Get unit IP address. @@ -490,6 +517,41 @@ async def check_tls(ops_test: OpsTest, unit_name: str, enabled: bool) -> bool: return False +async def check_tls_patroni_api(ops_test: OpsTest, unit_name: str, enabled: bool) -> bool: + """Returns whether TLS is enabled on Patroni REST API. + + Args: + ops_test: The ops test framework instance. + unit_name: The name of the unit where Patroni is running. + enabled: check if TLS is enabled/disabled + + Returns: + Whether TLS is enabled/disabled on Patroni REST API. + """ + unit_address = get_unit_address(ops_test, unit_name) + tls_ca = await get_tls_ca(ops_test, unit_name) + try: + for attempt in Retrying( + stop=stop_after_attempt(10), wait=wait_exponential(multiplier=1, min=2, max=30) + ): + with attempt, tempfile.NamedTemporaryFile() as temp_ca_file: + # Write the TLS CA to a temporary file to use it in a request. + temp_ca_file.write(tls_ca.encode("utf-8")) + temp_ca_file.seek(0) + + # The CA bundle file is used to validate the server certificate when + # TLS is enabled, otherwise True is set because it's the default value + # for the verify parameter. + health_info = requests.get( + f"{'https' if enabled else 'http'}://{unit_address}:8008/health", + verify=temp_ca_file.name if enabled else True, + ) + return health_info.status_code == 200 + except RetryError: + return False + return False + + async def scale_application(ops_test: OpsTest, application_name: str, count: int) -> None: """Scale a given application to a specific unit count. diff --git a/tests/integration/test_tls.py b/tests/integration/test_tls.py index 7e2ed47e68..764d1506ef 100644 --- a/tests/integration/test_tls.py +++ b/tests/integration/test_tls.py @@ -5,7 +5,11 @@ from pytest_operator.plugin import OpsTest from tests.helpers import METADATA -from tests.integration.helpers import DATABASE_APP_NAME, check_tls +from tests.integration.helpers import ( + DATABASE_APP_NAME, + check_tls, + check_tls_patroni_api, +) APP_NAME = METADATA["name"] TLS_CERTIFICATES_APP_NAME = "tls-certificates-operator" @@ -43,6 +47,7 @@ async def test_tls_enabled(ops_test: OpsTest) -> None: # Wait for all units enabling TLS. for unit in ops_test.model.applications[DATABASE_APP_NAME].units: assert await check_tls(ops_test, unit.name, enabled=True) + assert await check_tls_patroni_api(ops_test, unit.name, enabled=True) # Remove the relation. await ops_test.model.applications[DATABASE_APP_NAME].remove_relation( @@ -53,3 +58,4 @@ async def test_tls_enabled(ops_test: OpsTest) -> None: # Wait for all units disabling TLS. for unit in ops_test.model.applications[DATABASE_APP_NAME].units: assert await check_tls(ops_test, unit.name, enabled=False) + assert await check_tls_patroni_api(ops_test, unit.name, enabled=False) From 334a50d1da908dda09563dc10e5eeb70975e8135 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 21 Sep 2022 12:31:40 -0300 Subject: [PATCH 34/34] Add additional check --- tests/integration/helpers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 2204d8471e..3a674b94a0 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -530,6 +530,12 @@ async def check_tls_patroni_api(ops_test: OpsTest, unit_name: str, enabled: bool """ unit_address = get_unit_address(ops_test, unit_name) tls_ca = await get_tls_ca(ops_test, unit_name) + + # If there is no TLS CA in the relation, something is wrong in + # the relation between the TLS Certificates Operator and PostgreSQL. + if enabled and not tls_ca: + return False + try: for attempt in Retrying( stop=stop_after_attempt(10), wait=wait_exponential(multiplier=1, min=2, max=30)