diff --git a/lib/charms/opensearch/v0/constants_charm.py b/lib/charms/opensearch/v0/constants_charm.py index cabe958e0..73f9d0c8e 100644 --- a/lib/charms/opensearch/v0/constants_charm.py +++ b/lib/charms/opensearch/v0/constants_charm.py @@ -109,6 +109,8 @@ AdminUser = "admin" KibanaserverUser = "kibanaserver" KibanaserverRole = "kibana_server" +ClientUsersDict = "client_relation_users" + # Opensearch Snap revision OPENSEARCH_SNAP_REVISION = 58 # Keep in sync with `workload_version` file diff --git a/lib/charms/opensearch/v0/opensearch_base_charm.py b/lib/charms/opensearch/v0/opensearch_base_charm.py index 633d244b7..600eba13b 100644 --- a/lib/charms/opensearch/v0/opensearch_base_charm.py +++ b/lib/charms/opensearch/v0/opensearch_base_charm.py @@ -617,13 +617,15 @@ def _on_update_status(self, event: UpdateStatusEvent): deployment_desc = self.opensearch_peer_cm.deployment_desc() if self.upgrade_in_progress: - logger.debug("Skipping `remove_users_and_roles()` because upgrade is in-progress") + logger.debug( + "Skipping `remove_lingering_users_and_roles()` because upgrade is in-progress" + ) elif ( self.unit.is_leader() and deployment_desc and deployment_desc.typ == DeploymentType.MAIN_ORCHESTRATOR ): - self.user_manager.remove_users_and_roles() + self.opensearch_provider.remove_lingering_relation_users_and_roles() # If relation not broken - leave if self.model.get_relation("certificates") is not None: diff --git a/lib/charms/opensearch/v0/opensearch_plugins.py b/lib/charms/opensearch/v0/opensearch_plugins.py index 329b7e356..65cdba2f8 100644 --- a/lib/charms/opensearch/v0/opensearch_plugins.py +++ b/lib/charms/opensearch/v0/opensearch_plugins.py @@ -261,7 +261,7 @@ def _on_update_status(self, event): for relation in self.model.relations.get(ClientRelationName, []): self.opensearch_provider.update_endpoints(relation) - self.user_manager.remove_users_and_roles() + self.opensearch_provider.remove_lingering_relation_users_and_roles() # If relation not broken - leave if self.model.get_relation("certificates") is not None: return diff --git a/lib/charms/opensearch/v0/opensearch_relation_provider.py b/lib/charms/opensearch/v0/opensearch_relation_provider.py index 456d387f5..60e1bae62 100644 --- a/lib/charms/opensearch/v0/opensearch_relation_provider.py +++ b/lib/charms/opensearch/v0/opensearch_relation_provider.py @@ -28,6 +28,7 @@ ) from charms.opensearch.v0.constants_charm import ( ClientRelationName, + ClientUsersDict, IndexCreationFailed, KibanaserverRole, KibanaserverUser, @@ -198,7 +199,8 @@ def _on_index_requested(self, event: IndexRequestedEvent) -> None: # noqa """ if self.charm.upgrade_in_progress: logger.warning( - "Modifying relations during an upgrade is not supported. The charm may be in a broken, unrecoverable state" + "Modifying relations during an upgrade is not supported." + "The charm may be in a broken, unrecoverable state" ) event.defer() return @@ -233,7 +235,13 @@ def _on_index_requested(self, event: IndexRequestedEvent) -> None: # noqa username = self._relation_username(event.relation) hashed_pwd, pwd = generate_hashed_password() try: - self.create_opensearch_users(username, hashed_pwd, event.index, extra_user_roles) + self.create_opensearch_users( + username, + hashed_pwd, + event.index, + extra_user_roles, + relation_id=event.relation.id, + ) except OpenSearchUserMgmtError as err: logger.error(err) self.charm.status.set( @@ -258,9 +266,7 @@ def _on_index_requested(self, event: IndexRequestedEvent) -> None: # noqa # Clear old statuses set by this hook self.charm.status.clear(NewIndexRequested.format(index=event.index)) self.charm.status.clear(IndexCreationFailed.format(index=event.index)) - self.charm.status.clear( - UserCreationFailed.format(rel_name=ClientRelationName, id=event.relation.id) - ) + self.charm.status.clear(UserCreationFailed.format(rel_name=ClientRelationName, id=rel_id)) def validate_index_name(self, index_name: str) -> bool: """Validates that the index name provided in the relation is acceptable.""" @@ -285,11 +291,7 @@ def validate_index_name(self, index_name: str) -> bool: return True def create_opensearch_users( - self, - username: str, - hashed_pwd: str, - index: str, - extra_user_roles: str, + self, username: str, hashed_pwd: str, index: str, extra_user_roles: str, relation_id: int ): """Creates necessary opensearch users and permissions for this relation. @@ -299,6 +301,7 @@ def create_opensearch_users( index: the index to which the users must be granted access extra_user_roles: the level of permissions that the user should be given. Can be a comma-separated list of roles, which should result in a merged list of permissions. + relation_id: the relation id for this relation, if it exists Raises: OpenSearchUserMgmtError if user creation fails @@ -307,15 +310,11 @@ def create_opensearch_users( # Create a new role for this relation, encapsulating the permissions we care about. We # can't create a "default" and an "admin" role once because the permissions need to be # set to this relation's specific index. - self.user_manager.create_role( - role_name=username, - permissions=self.get_extra_user_role_permissions(extra_user_roles, index), - ) - roles = [username] - self.user_manager.create_user(username, roles, hashed_pwd) + permissions = self.get_extra_user_role_permissions(extra_user_roles, index) + self._put_relation_user(username, permissions, hashed_pwd, relation_id) self.user_manager.patch_user( username, - [{"op": "replace", "path": "/opendistro_security_roles", "value": roles}], + [{"op": "replace", "path": "/opendistro_security_roles", "value": [username]}], ) except OpenSearchUserMgmtError as err: logger.error(err) @@ -391,6 +390,8 @@ def _on_relation_departed(self, event: RelationDepartedEvent) -> None: if event.departing_unit == self.charm.unit: self.charm.peers_data.put(Scope.UNIT, self._depart_flag(event.relation), True) + self.remove_lingering_relation_users_and_roles(event.relation.id) + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: """Handle client relation-broken event.""" if not self.unit.is_leader(): @@ -401,9 +402,10 @@ def _on_relation_broken(self, event: RelationBrokenEvent) -> None: return if self.charm.upgrade_in_progress: logger.warning( - "Modifying relations during an upgrade is not supported. The charm may be in a broken, unrecoverable state" + "Modifying relations during an upgrade is not supported." + "The charm may be in a broken, unrecoverable state" ) - self.user_manager.remove_users_and_roles(event.relation.id) + self.remove_lingering_relation_users_and_roles(event.relation.id) def update_endpoints(self, relation: Relation, omit_endpoints: Optional[Set[str]] = None): """Updates endpoints in the databag for the given relation.""" @@ -438,3 +440,70 @@ def update_dashboards_password(self): pwd = self.secrets.get(Scope.APP, self.secrets.password_key(KibanaserverUser)) for relation in self.dashboards_relations: self.opensearch_provides.set_credentials(relation.id, KibanaserverUser, pwd) + + def _put_relation_user( + self, user: str, permissions: dict[str], hashed_pwd: str, relation_id: int + ): + """Create a relation user. + + Relation users are registered with a dedicated role which maps to the username, + and their name is saved in the databag for later reference. + """ + self.user_manager.create_role(role_name=user, permissions=permissions) + users = self.charm.peers_data.get_object(Scope.APP, ClientUsersDict) or {} + + if users.get(relation_id): + logger.warning( + "User %s is already registered in Peer Relation data for relation %d.", + user, + relation_id, + ) + + self.user_manager.create_user(user, [user], hashed_pwd) + users[str(relation_id)] = user + self.charm.peers_data.put_object(Scope.APP, ClientUsersDict, users) + + def remove_lingering_relation_users_and_roles( # noqa: C901 + self, departed_relation_id: int | None = None + ): + """Removes lingering relation users and roles from opensearch. + + Args: + departed_relation_id: if a relation is departing, pass in the ID and its user will be + deleted. + """ + if not self.opensearch.is_node_up() or not self.unit.is_leader(): + return + + relation_users = self.charm.peers_data.get_object(Scope.APP, ClientUsersDict) or {} + + if departed_relation_id and ( + not relation_users or departed_relation_id not in relation_users + ): + logging.warning( + "User for relation %d wasn't registered in internal cham workflows.", + departed_relation_id, + ) + + cleanup_rel_ids = [] + if departed_relation_id: + cleanup_rel_ids = [str(departed_relation_id)] + + rel_ids = [str(relation.id) for relation in self.opensearch_provides.relations] + cleanup_rel_ids += list(set(relation_users.keys()) - set(rel_ids)) + + for rel_id in cleanup_rel_ids: + if username := relation_users.get(rel_id): + try: + self.user_manager.remove_user(username) + except OpenSearchUserMgmtError: + logger.error(f"failed to remove user {username}") + + try: + self.user_manager.remove_role(username) + except OpenSearchUserMgmtError: + logger.error(f"failed to remove role {username}") + + del relation_users[rel_id] + + self.charm.peers_data.put_object(Scope.APP, ClientUsersDict, relation_users) diff --git a/lib/charms/opensearch/v0/opensearch_users.py b/lib/charms/opensearch/v0/opensearch_users.py index bf66a16a7..e5b85ad69 100644 --- a/lib/charms/opensearch/v0/opensearch_users.py +++ b/lib/charms/opensearch/v0/opensearch_users.py @@ -7,11 +7,10 @@ """ import logging -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional from charms.opensearch.v0.constants_charm import ( AdminUser, - ClientRelationName, COSRole, COSUser, KibanaserverUser, @@ -242,27 +241,6 @@ def patch_user(self, user_name: str, patches: List[Dict[str, any]]) -> Dict[str, return resp - def remove_users_and_roles(self, departed_relation_id: Optional[int] = None): - """Removes lingering relation users and roles from opensearch. - - Args: - departed_relation_id: if a relation is departing, pass in the ID and its user will be - deleted. - """ - if not self.opensearch.is_node_up() or not self.unit.is_leader(): - return - - relations = self.model.relations.get(ClientRelationName, []) - relation_users = set( - [ - f"{ClientRelationName}_{relation.id}" - for relation in relations - if relation.id != departed_relation_id - ] - ) - self._remove_lingering_users(relation_users) - self._remove_lingering_roles(relation_users) - def update_user_password(self, username: str, hashed_pwd: str = None): """Change user hashed password.""" resp = self.opensearch.request( @@ -273,6 +251,10 @@ def update_user_password(self, username: str, hashed_pwd: str = None): if resp.get("status") != "OK": raise OpenSearchError(f"{resp}") + ########################################################################## + # Dedicated functionalities + ########################################################################## + def put_internal_user(self, user: str, hashed_pwd: str): """User creation for specific system users.""" if user not in OpenSearchUsers: @@ -314,33 +296,3 @@ def put_internal_user(self, user: str, hashed_pwd: str): COSUser, [{"op": "replace", "path": "/opendistro_security_roles", "value": roles}], ) - - def _remove_lingering_users(self, relation_users: Set[str]): - app_users = relation_users | OpenSearchUsers - try: - database_users = set(self.get_users().keys()) - except OpenSearchUserMgmtError: - logger.error("failed to get users") - return - - for username in database_users - app_users: - try: - self.remove_user(username) - except OpenSearchUserMgmtError: - logger.error(f"failed to remove user {username}") - - def _remove_lingering_roles(self, roles: Set[str]): - try: - database_roles = set(self.get_roles().keys()) - except (OpenSearchUserMgmtError, OpenSearchHttpError): - logger.error("failed to get roles") - return - - for role in database_roles - roles: - if not role.startswith(f"{ClientRelationName}_"): - # This role was not created by this charm, so leave it alone - continue - try: - self.remove_role(role) - except OpenSearchUserMgmtError: - logger.error(f"failed to remove role {role}") diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index c9c705cd2..d05d51edc 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -2,7 +2,9 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. +import asyncio import logging +import shlex import subprocess import pytest @@ -70,6 +72,9 @@ async def test_deploy_and_remove_single_unit(ops_test: OpsTest) -> None: async def test_build_and_deploy(ops_test: OpsTest) -> None: """Build and deploy a couple of OpenSearch units.""" my_charm = await ops_test.build_charm(".") + model_config = MODEL_CONFIG + model_config["update-status-hook-interval"] = "1m" + await ops_test.model.set_config(MODEL_CONFIG) await ops_test.model.deploy( @@ -324,6 +329,34 @@ async def test_all_units_have_internal_users_synced(ops_test: OpsTest) -> None: @pytest.mark.group(1) @pytest.mark.abort_on_fail -async def test_remove_application(ops_test: OpsTest) -> None: - """Removes the application with two units.""" - await ops_test.model.remove_application(APP_NAME, block_until_done=True) +async def test_add_users_and_calling_update_status(ops_test: OpsTest) -> None: + """Add users and call update status.""" + leader_id = await get_leader_unit_id(ops_test) + leader_ip = await get_leader_unit_ip(ops_test) + test_url = f"https://{leader_ip}:9200/_plugins/_security/api/internalusers/my_user" + + http_resp_code = await http_request( + ops_test, + "PUT", + test_url, + resp_status_code=True, + payload={"hash": "1234"}, + ) + assert http_resp_code >= 200 and http_resp_code < 300 + + cmd = '"export JUJU_DISPATCH_PATH=hooks/update-status; ./dispatch"' + exec_cmd = f"juju exec -u opensearch/{leader_id} -m {ops_test.model.name} -- {cmd}" + try: + # The "normal" subprocess.run with "export ...; ..." cmd was failing + # Noticed that, for this case, canonical/jhack uses shlex instead to split. + # Adding it fixed the issue. + subprocess.run(shlex.split(exec_cmd)) + except Exception as e: + logger.error( + f"Failed to apply state: process exited with {e.returncode}; " + f"stdout = {e.stdout}; " + f"stderr = {e.stderr}.", + ) + await asyncio.sleep(300) + http_resp_code = await http_request(ops_test, "GET", test_url, resp_status_code=True) + assert http_resp_code >= 200 and http_resp_code < 300 diff --git a/tests/unit/lib/test_opensearch_base_charm.py b/tests/unit/lib/test_opensearch_base_charm.py index 242b0c479..959269c95 100644 --- a/tests/unit/lib/test_opensearch_base_charm.py +++ b/tests/unit/lib/test_opensearch_base_charm.py @@ -325,7 +325,9 @@ def test_on_start( @patch(f"{BASE_LIB_PATH}.opensearch_backups.OpenSearchBackup._is_restore_complete") @patch(f"{BASE_CHARM_CLASS}._stop_opensearch") @patch(f"{BASE_LIB_PATH}.opensearch_base_charm.cert_expiration_remaining_hours") - @patch(f"{BASE_LIB_PATH}.opensearch_users.OpenSearchUserManager.remove_users_and_roles") + @patch( + f"{BASE_LIB_PATH}.opensearch_relation_provider.OpenSearchProvider.remove_lingering_relation_users_and_roles" + ) def test_on_update_status(self, _, cert_expiration_remaining_hours, _stop_opensearch, __, ___): """Test on update status.""" with patch( diff --git a/tests/unit/lib/test_opensearch_relation_provider.py b/tests/unit/lib/test_opensearch_relation_provider.py index 63aacb3a9..3b1e7a874 100644 --- a/tests/unit/lib/test_opensearch_relation_provider.py +++ b/tests/unit/lib/test_opensearch_relation_provider.py @@ -1,17 +1,29 @@ # Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. +import json import unittest -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import ANY, MagicMock, PropertyMock, patch +import responses from charms.opensearch.v0.constants_charm import ( ClientRelationName, + ClientUsersDict, KibanaserverRole, KibanaserverUser, NodeLockRelationName, PeerRelationName, ) from charms.opensearch.v0.helper_security import generate_password +from charms.opensearch.v0.models import ( + App, + DeploymentDescription, + DeploymentState, + DeploymentType, + PeerClusterConfig, + StartMode, + State, +) from charms.opensearch.v0.opensearch_internal_data import Scope from charms.opensearch.v0.opensearch_users import OpenSearchUserMgmtError from ops.model import ActiveStatus, BlockedStatus @@ -19,6 +31,7 @@ from charm import OpenSearchOperatorCharm from tests.helpers import patch_network_get +from tests.unit.helpers import mock_response_nodes, mock_response_root DASHBOARDS_CHARM = "opensearch-dashboards" @@ -45,6 +58,18 @@ def setUp(self): self.client_rel_id = self.harness.add_relation(ClientRelationName, "application") self.harness.add_relation_unit(self.client_rel_id, "application/0") + def mock_deployment_desc(): + return DeploymentDescription( + config=PeerClusterConfig(cluster_name="", init_hold=False, roles=[]), + start=StartMode.WITH_GENERATED_ROLES, + pending_directives=[], + typ=DeploymentType.MAIN_ORCHESTRATOR, + app=App(model_uuid="model-uuid", name="opensearch"), + state=DeploymentState(value=State.ACTIVE), + ) + + self.charm.opensearch_peer_cm.deployment_desc = mock_deployment_desc + @patch("charm.OpenSearchOperatorCharm._purge_users") @patch("charms.opensearch.v0.opensearch_distro.YamlConfigSetter.put") @patch("charms.opensearch.v0.opensearch_distro.OpenSearchDistribution.is_node_up") @@ -97,7 +122,9 @@ def test_on_index_requested( event.index = "test_index" self.unit.status = ActiveStatus() self.opensearch_provider._on_index_requested(event) - _create_users.assert_called_with(username, hashed_pw, event.index, event.extra_user_roles) + _create_users.assert_called_with( + username, hashed_pw, event.index, event.extra_user_roles, relation_id=event.relation.id + ) _set_credentials.assert_called_with(event.relation.id, username, password) _set_version.assert_called_with(event.relation.id, _opensearch_version()) self.assertNotIsInstance(self.unit.status, BlockedStatus) @@ -183,10 +210,10 @@ def test_create_opensearch_users(self, _patch_user, _create_role, _create_user): patches = [ {"op": "replace", "path": "/opendistro_security_roles", "value": roles}, ] - self.opensearch_provider.create_opensearch_users( - username, hashed_pw, index, extra_user_roles + username, hashed_pw, index, extra_user_roles, relation_id=0 ) + # permissions and action groups are in extra_user_roles, so we create a new role. _create_role.assert_called_with( role_name=username, @@ -196,6 +223,9 @@ def test_create_opensearch_users(self, _patch_user, _create_role, _create_user): ) _create_user.assert_called_with(username, roles, hashed_pw) _patch_user.assert_called_with(username, patches) + assert self.harness.get_relation_data(self.peers_rel_id, self.charm.app.name)[ + ClientUsersDict + ] == json.dumps({self.peers_rel_id: username}) def test_on_relation_departed(self): event = MagicMock() @@ -215,7 +245,9 @@ def test_on_relation_departed(self): ) @patch("charms.opensearch.v0.opensearch_relation_provider.OpenSearchProvider._unit_departing") - @patch("charms.opensearch.v0.opensearch_users.OpenSearchUserManager.remove_users_and_roles") + @patch( + "charms.opensearch.v0.opensearch_relation_provider.OpenSearchProvider.remove_lingering_relation_users_and_roles" + ) @patch("charms.opensearch.v0.opensearch_distro.OpenSearchDistribution.is_node_up") @patch("charm.OpenSearchOperatorCharm._put_or_update_internal_user_leader") @patch("charm.OpenSearchOperatorCharm._purge_users") @@ -359,3 +391,120 @@ def test_update_dashboards_password( assert peer_password == new_pwd assert peer_password == rel1_secret.peek_content().get("password") assert peer_password == rel2_secret.peek_content().get("password") + + @responses.activate + # Mocks we are interested about + @patch("charms.opensearch.v0.opensearch_users.OpenSearchUserManager.remove_role") + @patch("charms.opensearch.v0.opensearch_users.OpenSearchUserManager.create_role") + @patch("charms.opensearch.v0.opensearch_users.OpenSearchUserManager.remove_user") + @patch("charms.opensearch.v0.opensearch_users.OpenSearchUserManager.create_user") + # Mocks to remove network operations + @patch("socket.socket.connect") + def test_avoid_removing_non_charmed_users_and_roles( + self, _, mock_create_user, mock_remove_user, mock_create_role, mock_remove_role + ): + + self.client_second_rel_id = self.harness.add_relation(ClientRelationName, "application") + relation_user1 = f"{ClientRelationName}_{self.client_rel_id}" + relation_user2 = f"{ClientRelationName}_{self.client_second_rel_id}" + + responses.add( + method="PUT", + url=f"https://{self.charm.opensearch.host}:9200/_plugins/_security/api/roles/{relation_user1}", + json={"status": "CREATED", "message": f"User {relation_user1} created"}, + ) + + responses.add( + method="PUT", + url=f"https://{self.charm.opensearch.host}:9200/_plugins/_security/api/roles/{relation_user2}", + json={"status": "CREATED", "message": f"User {relation_user2} created"}, + ) + + responses.add( + method="PUT", + url=f"https://{self.charm.opensearch.host}:9200/_plugins/_security/api/internalusers/{relation_user1}", + json={"status": "OK", "message": f"User {relation_user1} updated"}, + ) + responses.add( + method="PUT", + url=f"https://{self.charm.opensearch.host}:9200/_plugins/_security/api/internalusers/{relation_user2}", + json={"status": "OK", "message": f"User {relation_user2} updated"}, + ) + + responses.add( + method="PATCH", + url=f"https://{self.charm.opensearch.host}:9200/_plugins/_security/api/internalusers/{relation_user1}", + json={"status": "OK", "message": f"User {relation_user1} updated"}, + ) + responses.add( + method="PATCH", + url=f"https://{self.charm.opensearch.host}:9200/_plugins/_security/api/internalusers/{relation_user2}", + json={"status": "OK", "message": f"User {relation_user2} updated"}, + ) + + with self.harness.hooks_disabled(): + self.harness.set_leader(is_leader=True) + # Faking that there was a "leftover" user from a relation that's gone + self.harness.update_relation_data( + self.peers_rel_id, + f"{self.charm.app.name}", + {ClientUsersDict: json.dumps({999: f"{ClientRelationName}_lingering"})}, + ) + + mock_response_root(self.charm.unit_name, self.charm.opensearch.host) + mock_response_nodes(self.charm.unit_name, self.charm.opensearch.host) + + # 1. Testing relation user creation + self.harness.charm.opensearch_provider.create_opensearch_users( + username=relation_user1, + hashed_pwd="pw1", + index="some_index", + extra_user_roles="admin, somerole", + relation_id=self.client_rel_id, + ) + mock_create_user.assert_called_with(relation_user1, [relation_user1], "pw1") + mock_create_role.assert_called_with(role_name=relation_user1, permissions=ANY) + + self.harness.charm.opensearch_provider.create_opensearch_users( + username=relation_user2, + hashed_pwd="pw2", + index="some_index2", + extra_user_roles="somerole2", + relation_id=self.client_second_rel_id, + ) + mock_create_user.assert_called_with(relation_user2, [relation_user2], "pw2") + mock_create_role.assert_called_with(role_name=relation_user2, permissions=ANY) + + assert self.harness.get_relation_data(self.peers_rel_id, f"{self.charm.app.name}") == { + ClientUsersDict: json.dumps( + { + self.client_rel_id: f"{relation_user1}", + self.client_second_rel_id: f"{relation_user2}", + 999: f"{ClientRelationName}_lingering", + } + ) + } + + # 2/a. Removing lingering users w/o relation specified (used on 'update-status') + self.harness.charm.opensearch_provider.remove_lingering_relation_users_and_roles() + + # Mocks called as expected + mock_remove_user.assert_called_once_with(f"{ClientRelationName}_lingering") + mock_remove_role.assert_called_once_with(f"{ClientRelationName}_lingering") + + # 2/b. Removing lingering users of a specific relation that's removed + # (on 'relation-broken', 'relation-departed') + mock_remove_user.reset_mock() + mock_remove_role.reset_mock() + + self.harness.remove_relation(self.client_second_rel_id) + assert self.harness.get_relation_data(self.peers_rel_id, f"{self.charm.app.name}") == { + ClientUsersDict: json.dumps({self.client_rel_id: f"{relation_user1}"}) + } + + mock_remove_user.assert_called_once_with( + f"{ClientRelationName}_{self.client_second_rel_id}" + ) + mock_remove_role.assert_called_once_with( + f"{ClientRelationName}_{self.client_second_rel_id}" + ) diff --git a/tests/unit/lib/test_opensearch_users.py b/tests/unit/lib/test_opensearch_users.py index 827ecd75e..edbbb4716 100644 --- a/tests/unit/lib/test_opensearch_users.py +++ b/tests/unit/lib/test_opensearch_users.py @@ -6,14 +6,41 @@ import unittest from unittest.mock import MagicMock, patch +# Imports to simulate designated imports order +# (Otherwise circular dependency may be reported, +# that is NOT supposed to ever happen for real by design. +import charms.opensearch.v0.helper_cluster # noqa +import charms.opensearch.v0.opensearch_distro # noqa import pytest +from charms.opensearch.v0.constants_charm import ClientRelationName, PeerRelationName +from charms.opensearch.v0.models import ( + App, + DeploymentDescription, + DeploymentState, + DeploymentType, + PeerClusterConfig, + StartMode, + State, +) from charms.opensearch.v0.opensearch_users import ( OpenSearchUserManager, OpenSearchUserMgmtError, ) +from ops.testing import Harness +from charm import OpenSearchOperatorCharm from tests.helpers import patch_network_get +PEERS_USER_DICT_JSON = f"""{{ + "0": ["{ClientRelationName}_2"], + "1": ["{ClientRelationName}_1"] +}}""" # returns user list + +PEERS_ROLE_DICT_JSON = f"""{{ + "0": ["admin", "other_role1", "{ClientRelationName}_remove_pls"], + "1": ["admin", "other_role1", "{ClientRelationName}_test"] +}}""" # returns role list + @patch_network_get("1.1.1.1") class TestOpenSearchUserManager(unittest.TestCase): @@ -22,6 +49,25 @@ def setUp(self): self.opensearch = self.charm.opensearch self.mgr = OpenSearchUserManager(self.charm) + self.harness = Harness(OpenSearchOperatorCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + self.charm = self.harness.charm + self.peer_rel_id = self.harness.add_relation(PeerRelationName, self.charm.app.name) + + def mock_deployment_desc(): + return DeploymentDescription( + config=PeerClusterConfig(cluster_name="", init_hold=False, roles=[]), + start=StartMode.WITH_GENERATED_ROLES, + pending_directives=[], + typ=DeploymentType.MAIN_ORCHESTRATOR, + app=App(model_uuid="model-uuid", name="opensearch"), + state=DeploymentState(value=State.ACTIVE), + ) + + self.charm.opensearch_peer_cm.deployment_desc = mock_deployment_desc + @patch("charms.opensearch.v0.opensearch_distro.OpenSearchDistribution.request") def test_create_role(self, _): self.opensearch.request.return_value = {"status": "not ok"}