diff --git a/docs/pagure_fas_groups_sync.md b/docs/pagure_fas_groups_sync.md new file mode 100644 index 0000000..6cd3497 --- /dev/null +++ b/docs/pagure_fas_groups_sync.md @@ -0,0 +1,18 @@ +# Sync Groups From FAS to Pagure + +Toddler for synchronizing FAS groups with Pagure groups was created from https://pagure.io/pagure-utility/blob/master/f/sync_fas_group_membership.py script. It is triggered either by group membership messages or by `toddlers.trigger` message. + +In case of group membership message it works as following: + +1. Check if we care about the group (it's in configuration for toddler) +1. Add member from group (currently there is no message about user being removed from group) + +In case of `toddlers.trigger` message it compares all the groups in configuration +and remove/add users to pagure group based on the changes in FAS. + +## Accepted topics + +The sync toddler accepts following topics. + +* org.fedoraproject.*.fas.group.member.sponsor - New member added to group +* org.fedoraproject.*.toddlers.trigger.pagure_fas_groups_sync - Message triggered by toddlers cron diff --git a/requirements.txt b/requirements.txt index a4cf597..67fb8d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ fedora-messaging-git-hook-messages GitPython koji requests +noggin-messages pagure-messages pyGObject python-fedora diff --git a/tests/plugins/test_pagure_fas_groups_sync.py b/tests/plugins/test_pagure_fas_groups_sync.py new file mode 100644 index 0000000..07c6b0c --- /dev/null +++ b/tests/plugins/test_pagure_fas_groups_sync.py @@ -0,0 +1,409 @@ +""" +Unit tests for `toddlers.plugins.scm_request_processor` +""" + +import logging +from unittest.mock import Mock, patch + +from fedora_messaging.api import Message +from noggin_messages import MemberSponsorV1 +import pytest + +from toddlers.exceptions import PagureError +from toddlers.plugins import pagure_fas_groups_sync + + +class TestAcceptsTopic: + """ + Test class for `toddlers.plugins.pagure_fas_groups_sync.PagureFASGroupsSync.accepts_topic` + method. + """ + + toddler_cls = pagure_fas_groups_sync.PagureFASGroupsSync + + def test_accepts_topic_invalid(self, toddler): + """ + Assert that invalid topic is not accepted. + """ + assert toddler.accepts_topic("foo.bar") is False + + @pytest.mark.parametrize( + "topic", + [ + "org.fedoraproject.*.fas.group.member.sponsor", + "org.fedoraproject.*.toddlers.trigger.pagure_fas_groups_sync", + "org.fedoraproject.stg.fas.group.member.sponsor", + "org.fedoraproject.stg.toddlers.trigger.pagure_fas_groups_sync", + "org.fedoraproject.prod.fas.group.member.sponsor", + "org.fedoraproject.prod.toddlers.trigger.pagure_fas_groups_sync", + ], + ) + def test_accepts_topic_valid(self, topic, toddler): + """ + Assert that valid topics are accepted. + """ + assert toddler.accepts_topic(topic) + + +class TestProcess: + """ + Test class for `toddlers.plugins.pagure_fas_groups_sync.PagureFASGroupsSync.process` method. + """ + + toddler_cls = pagure_fas_groups_sync.PagureFASGroupsSync + + @patch("toddlers.utils.pagure.set_pagure") + @patch("toddlers.utils.fedora_account.set_fasjson") + def test_process_trigger_message(self, mock_fas, mock_pagure, toddler): + """ + Assert that trigger message is processed correctly. + """ + # Preparation + config = {"group_map": {"fas_group": "pagure_group"}} + msg = Message() + msg.topic = "org.fedoraproject.prod.toddlers.trigger.pagure_fas_groups_sync" + + # Test + with patch( + "toddlers.plugins.pagure_fas_groups_sync.PagureFASGroupsSync.sync_group" + ) as mock_sync_group: + toddler.process(config, msg) + + # Assertions + mock_sync_group.assert_called_with("fas_group", "pagure_group") + + mock_fas.assert_called_with(config) + mock_pagure.assert_called_with(config) + + @patch("toddlers.utils.pagure.set_pagure") + @patch("toddlers.utils.fedora_account.set_fasjson") + def test_process_sponsor_message(self, mock_fas, mock_pagure, toddler): + """ + Assert that sponsor message is processed correctly. + """ + # Preparation + mock_pagure_obj = Mock() + mock_pagure.return_value = mock_pagure_obj + config = {"group_map": {"fas_group": "pagure_group"}} + msg = MemberSponsorV1( + {"msg": {"agent": "agent", "user": "user", "group": "fas_group"}} + ) + + # Test + toddler.process(config, msg) + + # Assertions + mock_pagure_obj.add_member_to_group.assert_called_with("user", "pagure_group") + + mock_fas.assert_called_with(config) + mock_pagure.assert_called_with(config) + + @patch("toddlers.utils.pagure.set_pagure") + @patch("toddlers.utils.fedora_account.set_fasjson") + def test_process_sponsor_message_user_not_in_pagure( + self, mock_fas, mock_pagure, toddler + ): + """ + Assert that sponsor message is processed correctly when user + doesn't exist in pagure. + """ + # Preparation + mock_pagure_obj = Mock() + mock_pagure_obj.user_exists.return_value = False + mock_pagure.return_value = mock_pagure_obj + config = {"group_map": {"fas_group": "pagure_group"}} + msg = MemberSponsorV1( + {"msg": {"agent": "agent", "user": "user", "group": "fas_group"}} + ) + + # Test + toddler.process(config, msg) + + # Assertions + mock_pagure_obj.add_member_to_group.assert_not_called() + + mock_fas.assert_called_with(config) + mock_pagure.assert_called_with(config) + + @patch("toddlers.utils.pagure.set_pagure") + @patch("toddlers.utils.fedora_account.set_fasjson") + def test_process_sponsor_message_failure( + self, mock_fas, mock_pagure, caplog, toddler + ): + """ + Assert that failure during processing sponsor message is handled correctly. + """ + # Preparation + caplog.set_level(logging.ERROR) + mock_pagure_obj = Mock() + mock_pagure_obj.add_member_to_group.side_effect = PagureError("PagureError") + mock_pagure.return_value = mock_pagure_obj + config = {"group_map": {"fas_group": "pagure_group"}} + msg = MemberSponsorV1( + {"msg": {"agent": "agent", "user": "user", "group": "fas_group"}} + ) + + # Test + toddler.process(config, msg) + + # Assertions + mock_pagure_obj.add_member_to_group.assert_called_with("user", "pagure_group") + + mock_fas.assert_called_with(config) + mock_pagure.assert_called_with(config) + + assert caplog.records[-1].message == "PagureError" + + @patch("toddlers.utils.pagure.set_pagure") + @patch("toddlers.utils.fedora_account.set_fasjson") + def test_process_sponsor_message_skipping( + self, mock_fas, mock_pagure, caplog, toddler + ): + """ + Assert that group is skipped when we don't care about it. + """ + # Preparation + caplog.set_level(logging.INFO) + mock_pagure_obj = Mock() + mock_pagure.return_value = mock_pagure_obj + config = {"group_map": {"fas_group": "pagure_group"}} + msg = MemberSponsorV1( + {"msg": {"agent": "agent", "user": "user", "group": "some_group"}} + ) + + # Test + toddler.process(config, msg) + + # Assertions + mock_pagure_obj.add_member_to_group.assert_not_called() + + mock_fas.assert_called_with(config) + mock_pagure.assert_called_with(config) + + assert caplog.records[-1].message.startswith( + "User 'user' was added to group 'some_group'" + ) + + +class TestSyncGroup: + """ + Test class for `toddlers.plugins.pagure_fas_groups_sync.sync_group` function. + """ + + def setup_method(self): + """Prepare the toddler object.""" + self.sync_object = pagure_fas_groups_sync.PagureFASGroupsSync() + self.sync_object.pagure = Mock() + + @patch("toddlers.plugins.pagure_fas_groups_sync.fedora_account") + def test_sync_group_add_member(self, mock_fas): + """Assert that adding member to group works as intended.""" + # Preparation + fas_group = "fas_group" + pagure_group = "pagure_group" + self.sync_object.pagure.get_group_members.return_value = ["user1"] + mock_fas.get_group_member.return_value = ["user1", "user2"] + + # Test + self.sync_object.sync_group(fas_group, pagure_group) + + # Assertions + mock_fas.get_group_member.assert_called_with(fas_group) + self.sync_object.pagure.get_group_members.assert_called_with(pagure_group) + + self.sync_object.pagure.add_member_to_group.assert_called_with( + "user2", pagure_group + ) + self.sync_object.pagure.remove_member_from_group.assert_not_called() + + @patch("toddlers.plugins.pagure_fas_groups_sync.fedora_account") + def test_sync_group_add_member_user_not_in_pagure(self, mock_fas): + """ + Assert that adding member to group works as intended + when the user doesn't exist in pagure. + """ + # Preparation + fas_group = "fas_group" + pagure_group = "pagure_group" + self.sync_object.pagure.get_group_members.return_value = ["user1"] + self.sync_object.pagure.user_exists.return_value = False + mock_fas.get_group_member.return_value = ["user1", "user2"] + + # Test + self.sync_object.sync_group(fas_group, pagure_group) + + # Assertions + mock_fas.get_group_member.assert_called_with(fas_group) + self.sync_object.pagure.get_group_members.assert_called_with(pagure_group) + + self.sync_object.pagure.add_member_to_group.assert_not_called() + self.sync_object.pagure.remove_member_from_group.assert_not_called() + + @patch("toddlers.plugins.pagure_fas_groups_sync.fedora_account") + def test_sync_group_add_member_failure(self, mock_fas, caplog): + """Assert that failing to adding member to group works as intended.""" + # Preparation + caplog.set_level(logging.ERROR) + fas_group = "fas_group" + pagure_group = "pagure_group" + self.sync_object.pagure.get_group_members.return_value = ["user1"] + mock_fas.get_group_member.return_value = ["user1", "user2"] + self.sync_object.pagure.add_member_to_group.side_effect = PagureError( + "PagureError" + ) + + # Test + self.sync_object.sync_group(fas_group, pagure_group) + + # Assertions + mock_fas.get_group_member.assert_called_with(fas_group) + self.sync_object.pagure.get_group_members.assert_called_with(pagure_group) + + self.sync_object.pagure.add_member_to_group.assert_called_with( + "user2", pagure_group + ) + self.sync_object.pagure.remove_member_from_group.assert_not_called() + assert caplog.records[-1].message == "PagureError" + + @patch("toddlers.plugins.pagure_fas_groups_sync.fedora_account") + def test_sync_group_remove_member(self, mock_fas): + """Assert that removing member from group works as intended.""" + # Preparation + fas_group = "fas_group" + pagure_group = "pagure_group" + self.sync_object.pagure.get_group_members.return_value = ["user1", "user2"] + mock_fas.get_group_member.return_value = ["user1"] + + # Test + self.sync_object.sync_group(fas_group, pagure_group) + + # Assertions + mock_fas.get_group_member.assert_called_with(fas_group) + self.sync_object.pagure.get_group_members.assert_called_with(pagure_group) + + self.sync_object.pagure.remove_member_from_group.assert_called_with( + "user2", pagure_group + ) + self.sync_object.pagure.add_member_to_group.assert_not_called() + + @patch("toddlers.plugins.pagure_fas_groups_sync.fedora_account") + def test_sync_group_remove_member_user_not_in_pagure(self, mock_fas): + """ + Assert that removing member from group works + as intended when user doesn't exist in pagure. + """ + # Preparation + fas_group = "fas_group" + pagure_group = "pagure_group" + self.sync_object.pagure.get_group_members.return_value = ["user1", "user2"] + self.sync_object.pagure.user_exists.return_value = False + mock_fas.get_group_member.return_value = ["user1"] + + # Test + self.sync_object.sync_group(fas_group, pagure_group) + + # Assertions + mock_fas.get_group_member.assert_called_with(fas_group) + self.sync_object.pagure.get_group_members.assert_called_with(pagure_group) + + self.sync_object.pagure.remove_member_from_group.assert_not_called() + self.sync_object.pagure.add_member_to_group.assert_not_called() + + @patch("toddlers.plugins.pagure_fas_groups_sync.fedora_account") + def test_sync_group_remove_member_failure(self, mock_fas, caplog): + """Assert that removing member from group works as intended.""" + # Preparation + caplog.set_level(logging.ERROR) + fas_group = "fas_group" + pagure_group = "pagure_group" + self.sync_object.pagure.get_group_members.return_value = ["user1", "user2"] + mock_fas.get_group_member.return_value = ["user1"] + self.sync_object.pagure.remove_member_from_group.side_effect = PagureError( + "PagureError" + ) + + # Test + self.sync_object.sync_group(fas_group, pagure_group) + + # Assertions + mock_fas.get_group_member.assert_called_with(fas_group) + self.sync_object.pagure.get_group_members.assert_called_with(pagure_group) + + self.sync_object.pagure.remove_member_from_group.assert_called_with( + "user2", pagure_group + ) + self.sync_object.pagure.add_member_to_group.assert_not_called() + assert caplog.records[-1].message == "PagureError" + + @patch("toddlers.plugins.pagure_fas_groups_sync.fedora_account") + @pytest.mark.parametrize( + "mock_fas_group, mock_pagure_group", [([], ["user"]), (["user"], [])] + ) + def test_sync_group_group_empty(self, mock_fas, mock_fas_group, mock_pagure_group): + """ + Assert that nothing is done if any of the retrieved groups is empty. + That should not happen, there always needs to be at least one member + in the group. + """ + # Preparation + fas_group = "fas_group" + pagure_group = "pagure_group" + self.sync_object.pagure.get_group_members.return_value = mock_pagure_group + mock_fas.get_group_member.return_value = mock_fas_group + + # Test + self.sync_object.sync_group(fas_group, pagure_group) + + # Assertions + mock_fas.get_group_member.assert_called_with(fas_group) + self.sync_object.pagure.get_group_members.assert_called_with(pagure_group) + + self.sync_object.pagure.add_member_to_group.assert_not_called() + self.sync_object.pagure.remove_member_from_group.assert_not_called() + + +class TestMain: + """ + Test class for `toddlers.plugins.pagure_fas_groups_sync.main` function. + """ + + def test_main_no_args(self, capsys): + """Assert that help is printed if no arg is provided.""" + # Test + with pytest.raises(SystemExit): + pagure_fas_groups_sync.main([]) + + # Assertions + out, err = capsys.readouterr() + assert out == "" + # Expecting something along these lines, but don't make the test too tight: + # + # usage: pytest [-h] [--dry-run] [-q | --debug] conf [username] + # pytest: error: the following arguments are required: conf + assert err.startswith("usage:") + assert "error: the following arguments are required:" in err + + @patch("toddlers.utils.pagure.set_pagure") + @patch("toddlers.utils.fedora_account.set_fasjson") + @patch("toddlers.plugins.pagure_fas_groups_sync.PagureFASGroupsSync.sync_group") + def test_main(self, mock_sync_group, mock_fas, mock_pagure): + """Assert that main is processed correctly.""" + # Test + pagure_fas_groups_sync.main( + [ + "--api-key", + "api_key", + "--group", + "fas_group", + "--target-group", + "pagure_group", + "--debug", + ] + ) + + # Assertions + mock_pagure.assert_called_with( + {"pagure_url": "https://pagure.io", "pagure_api_key": "api_key"} + ) + mock_fas.assert_called_with({"fas_url": "https://fasjson.fedoraproject.org"}) + mock_sync_group.assert_called_with("fas_group", "pagure_group") diff --git a/tests/utils/test_pagure.py b/tests/utils/test_pagure.py index fb675c1..927aa2c 100644 --- a/tests/utils/test_pagure.py +++ b/tests/utils/test_pagure.py @@ -3,7 +3,7 @@ Unit tests for `toddlers.utils.pagure`. """ import json -from unittest.mock import call, Mock, patch +from unittest.mock import call, MagicMock, Mock, patch import pytest @@ -1455,3 +1455,216 @@ class TestPagureAssignMaintainerToProject: data=json.dumps(payload), headers=self.pagure.get_auth_header(), ) + + +class TestPagureAddMemberToGroup: + """ + Test class for `toddlers.pagure.Pagure.add_member_to_group` method. + """ + + def setup_method(self): + """ + Setup method for the test class. + """ + config = { + "pagure_url": "https://pagure.io", + "pagure_api_key": "Very secret key", + } + self.pagure = pagure.set_pagure(config) + self.pagure._requests_session = Mock() + + def test_add_member_to_group(self): + """ + Assert that adding member to group is processed correctly. + """ + response_mock = Mock() + + self.pagure._requests_session.post.return_value = response_mock + + user = "conrad_kurze" + group = "adeptus_astartes" + payload = { + "user": user, + } + + self.pagure.add_member_to_group(user, group) + + self.pagure._requests_session.post.assert_called_with( + "https://pagure.io/api/0/group/" + group + "/add", + data=json.dumps(payload), + headers=self.pagure.get_auth_header(), + ) + + def test_add_member_to_group_failure(self): + """ + Assert that failing to add member to group is handled correctly. + """ + response_mock = MagicMock() + response_mock.ok = False + + self.pagure._requests_session.post.return_value = response_mock + + user = "conrad_kurze" + group = "adeptus_astartes" + payload = { + "user": user, + } + + expected_error = "Couldn't add user '{0}' to group '{1}'".format(user, group) + + with pytest.raises(PagureError, match=expected_error): + self.pagure.add_member_to_group(user, group) + + self.pagure._requests_session.post.assert_called_with( + "https://pagure.io/api/0/group/" + group + "/add", + data=json.dumps(payload), + headers=self.pagure.get_auth_header(), + ) + + +class TestPagureRemoveMemberFromGroup: + """ + Test class for `toddlers.pagure.Pagure.remove_member_from_group` method. + """ + + def setup_method(self): + """ + Setup method for the test class. + """ + config = { + "pagure_url": "https://pagure.io", + "pagure_api_key": "Very secret key", + } + self.pagure = pagure.set_pagure(config) + self.pagure._requests_session = Mock() + + def test_remove_member_from_group(self): + """ + Assert that removing member from group is processed correctly. + """ + response_mock = Mock() + + self.pagure._requests_session.post.return_value = response_mock + + user = "conrad_kurze" + group = "adeptus_astartes" + payload = { + "user": user, + } + + self.pagure.remove_member_from_group(user, group) + + self.pagure._requests_session.post.assert_called_with( + "https://pagure.io/api/0/group/" + group + "/remove", + data=json.dumps(payload), + headers=self.pagure.get_auth_header(), + ) + + def test_remove_member_from_group_failure(self): + """ + Assert that failing to remove member from group is handled correctly. + """ + response_mock = MagicMock() + response_mock.ok = False + + self.pagure._requests_session.post.return_value = response_mock + + user = "conrad_kurze" + group = "adeptus_astartes" + payload = { + "user": user, + } + + expected_error = "Couldn't remove user '{0}' from group '{1}'".format( + user, group + ) + + with pytest.raises(PagureError, match=expected_error): + self.pagure.remove_member_from_group(user, group) + + self.pagure._requests_session.post.assert_called_with( + "https://pagure.io/api/0/group/" + group + "/remove", + data=json.dumps(payload), + headers=self.pagure.get_auth_header(), + ) + + +class TestPagureGetGroupMembers: + """ + Test class for `toddlers.pagure.Pagure.get_group_members` method. + """ + + def setup_method(self): + """ + Setup method for the test class. + """ + config = { + "pagure_url": "https://pagure.io", + "pagure_api_key": "Very secret key", + } + self.pagure = pagure.set_pagure(config) + self.pagure._requests_session = Mock() + + def test_get_group_members(self): + """ + Assert that retrieving members in group is processed correctly. + """ + response_mock = Mock() + user = "conrad_kurze" + data = {"members": [user]} + response_mock.json.return_value = data + + self.pagure._requests_session.get.return_value = response_mock + + group = "adeptus_astartes" + + result = self.pagure.get_group_members(group) + + assert result == [user] + + self.pagure._requests_session.get.assert_called_with( + "https://pagure.io/api/0/group/" + group, + headers=self.pagure.get_auth_header(), + ) + + def test_get_group_members_no_group(self): + """ + Assert that when group doesn't exist is handled correctly. + """ + response_mock = MagicMock() + response_mock.ok = False + response_mock.status_code = 404 + + self.pagure._requests_session.get.return_value = response_mock + + group = "adeptus_astartes" + + result = self.pagure.get_group_members(group) + + assert not result + + self.pagure._requests_session.get.assert_called_with( + "https://pagure.io/api/0/group/" + group, + headers=self.pagure.get_auth_header(), + ) + + def test_get_group_members_failure(self): + """ + Assert that failing to obtain members of group is handled correctly. + """ + response_mock = MagicMock() + response_mock.ok = False + + self.pagure._requests_session.get.return_value = response_mock + + group = "adeptus_astartes" + + expected_error = "Couldn't get members of group '{0}'".format(group) + + with pytest.raises(PagureError, match=expected_error): + self.pagure.get_group_members(group) + + self.pagure._requests_session.get.assert_called_with( + "https://pagure.io/api/0/group/" + group, + headers=self.pagure.get_auth_header(), + ) diff --git a/toddlers.toml.example b/toddlers.toml.example index 0888890..2388333 100644 --- a/toddlers.toml.example +++ b/toddlers.toml.example @@ -295,6 +295,18 @@ bug_fixes = '2022-11-26' security_fixes = '2022-03-08' bug_fixes = '2022-03-08' +[consumer_config.pagure_fas_groups_sync] +pagure_url = "https://pagure.io" +# Token needs to have following permissions: +# - adding_member_to_group +# - removing_member_from_group +pagure_api_key = "API token for pagure" + +[consumer_config.pagure_fas_groups_sync.group_map] +#Mapping of FAS groups to Pagure groups +infra-sig = 'fedora-infra' + + [qos] prefetch_size = 0 prefetch_count = 25 diff --git a/toddlers/plugins/pagure_fas_groups_sync.py b/toddlers/plugins/pagure_fas_groups_sync.py new file mode 100644 index 0000000..227f1b5 --- /dev/null +++ b/toddlers/plugins/pagure_fas_groups_sync.py @@ -0,0 +1,205 @@ +""" +This toddler synchronizes groups from FAS to pagure.io. + +Authors: Michal Konecny + +""" + +import argparse +import json +import logging +import sys +from typing import Dict + +from fedora_messaging.api import Message +from noggin_messages import MemberSponsorV1 + +from toddlers.base import ToddlerBase +from toddlers.exceptions import PagureError +from toddlers.utils import fedora_account, pagure + +_log = logging.getLogger(__name__) + + +class PagureFASGroupsSync(ToddlerBase): + """ + Synchronize FAS groups with Pagure. + + Consumes group membership change messages and process them + and consumes trigger to sync all the configured groups. + """ + + name: str = "pagure_fas_groups_sync" + + amqp_topics: list = [ + "org.fedoraproject.*.fas.group.member.sponsor", + "org.fedoraproject.*.toddlers.trigger.pagure_fas_groups_sync", + ] + + # Pagure object + pagure: pagure.Pagure + + # Group mapping from FAS to pagure + group_map: Dict[str, str] + + def accepts_topic(self, topic: str) -> bool: + """ + Return a boolean if this toddler consumes messages from topic. + + :arg topic: Topic to check. + + :returns: True if topic is accepted, False otherwise. + """ + if topic.startswith("org.fedoraproject."): + if topic.endswith("fas.group.member.sponsor"): + return True + if topic.endswith("toddlers.trigger.pagure_fas_groups_sync"): + return True + + return False + + def process( + self, + config: dict, + message: Message, + ) -> None: + """Process a given message. + + :arg config: Toddlers configuration + :arg message: Message to process + """ + if _log.isEnabledFor(logging.DEBUG): + _log.debug("Processing message:\n%s", json.dumps(message.body, indent=2)) + topic = message.topic + + self.pagure = pagure.set_pagure(config) + fedora_account.set_fasjson(config) + + group_map = config.get("group_map", {}) + + if topic.endswith("toddlers.trigger.pagure_fas_groups_sync"): + for group in group_map: + self.sync_group(group, group_map[group]) + + if topic.endswith("fas.group.member.sponsor"): + member_sponsor_message = MemberSponsorV1(message.body) + user = member_sponsor_message.user_name + for group in member_sponsor_message.groups: + if group in group_map: + try: + if self.pagure.user_exists(user): + self.pagure.add_member_to_group(user, group_map[group]) + except PagureError as e: + _log.exception(str(e)) + else: + _log.info( + "User '%s' was added to group '%s', " + "but we don't care about this group. " + "Skipping.", + user, + group, + ) + + def sync_group(self, fas_group: str, pagure_group: str): + """ + Synchronize FAS group with Pagure group. + + Compares FAS group and Pagure group and modifies the Pagure + group to correspond with the specified FAS group. + + :arg fas_group: FAS group to check + :arg pagure_group: Pagure group to sync + """ + _log.info( + "Syncing FAS group '%s' with pagure group '%s'", fas_group, pagure_group + ) + group_members_fas = fedora_account.get_group_member(fas_group) + _log.debug("FAS group members: [%s]", group_members_fas) + group_members_pagure = self.pagure.get_group_members(pagure_group) + _log.debug("Pagure group members: [%s]", group_members_pagure) + + if not group_members_fas or not group_members_pagure: + _log.warning( + "FAS group or Pagure group is empty. This shouldn't happen. Skipping sync." + ) + return + add_members = [ + user for user in group_members_fas if user not in group_members_pagure + ] + remove_members = [ + user for user in group_members_pagure if user not in group_members_fas + ] + + for user in add_members: + try: + if self.pagure.user_exists(user): + self.pagure.add_member_to_group(user, pagure_group) + except PagureError as e: + _log.exception(str(e)) + + for user in remove_members: + try: + if self.pagure.user_exists(user): + self.pagure.remove_member_from_group(user, pagure_group) + except PagureError as e: + _log.exception(str(e)) + + _log.info("Sync complete") + + +def main(args): + """Run toddler class without trigger message.""" + parser = argparse.ArgumentParser( + description="Sync the group membership from FAS to pagure." + ) + parser.add_argument( + "--fas-url", + default="https://fasjson.fedoraproject.org", + help="URL to the FAS instance to use.", + ) + parser.add_argument( + "--pagure-url", + default="https://pagure.io", + help="URL to the Pagure instance to use.", + ) + parser.add_argument( + "--api-key", + help="Pagure API key to use.", + required=True, + ) + parser.add_argument( + "--group", + help="Sync a specific group from FAS to pagure.", + required=True, + ) + parser.add_argument( + "--target-group", + help="Name of the group on the pagure side that the group specified " + "in --group should be synced to.", + required=True, + ) + parser.add_argument( + "--debug", + dest="debug", + action="store_true", + default=False, + help="Print the debugging output", + ) + args = parser.parse_args(args) + _log.addHandler(logging.StreamHandler(sys.stdout)) + if args.debug: + _log.setLevel(logging.DEBUG) + + pagure_fas_sync_toddler = PagureFASGroupsSync() + pagure_fas_sync_toddler.pagure = pagure.set_pagure( + {"pagure_url": args.pagure_url, "pagure_api_key": args.api_key} + ) + fedora_account.set_fasjson({"fas_url": args.fas_url}) + pagure_fas_sync_toddler.sync_group(args.group, args.target_group) + + +if __name__ == "__main__": # pragma: no cover + try: + main(sys.argv[1:]) + except KeyboardInterrupt: + pass diff --git a/toddlers/utils/pagure.py b/toddlers/utils/pagure.py index b3c2ff5..ea15e26 100644 --- a/toddlers/utils/pagure.py +++ b/toddlers/utils/pagure.py @@ -18,7 +18,7 @@ Examples: import json import logging -from typing import Any, Optional +from typing import Any, List, Optional from toddlers.exceptions import PagureError from toddlers.utils import requests @@ -936,3 +936,169 @@ class Pagure: namespace, repo ) ) + + def add_member_to_group(self, user: str, group: str) -> None: + """ + Add member to pagure group. + + Params: + user: User to add + group: Group to add user to + + Raises: + `toddlers.utils.exceptions.PagureError``: When user can't be added to group. + """ + api_url = "{0}/api/0/group/{1}/add".format(self._pagure_url, group) + + payload = {"user": user} + headers = self.get_auth_header() + + log.debug("Adding user '%s' to group '%s'", user, group) + response = self._requests_session.post( + api_url, data=json.dumps(payload), headers=headers + ) + + if not response.ok: + log.error( + "Error when adding user '%s' to group '%s'. " "Got status_code '%s'.", + user, + group, + response.status_code, + ) + + response_json = None + if response.headers.get("content-type") == "application/json": + response_json = response.json() + log.error("Received response: %s", response.json()) + + raise PagureError( + ( + "Couldn't add user '{0}' to group '{1}'\n\n" + "Request to '{2}':\n\n" + "Response:\n" + "{3}\n\n" + "Status code: {4}" + ).format( + user, + group, + api_url, + response_json, + response.status_code, + ) + ) + + log.debug("User '%s' added to group '%s'", user, group) + + def remove_member_from_group(self, user: str, group: str) -> None: + """ + Remove member from pagure group. + + Params: + user: User to remove + group: Group to remove user from + + Raises: + `toddlers.utils.exceptions.PagureError``: When user can't be removed to group. + """ + api_url = "{0}/api/0/group/{1}/remove".format(self._pagure_url, group) + payload = {"user": user} + headers = self.get_auth_header() + + log.debug("Removing user '%s' from group '%s'", user, group) + response = self._requests_session.post( + api_url, data=json.dumps(payload), headers=headers + ) + + if not response.ok: + log.error( + "Error when removing user '%s' from group '%s'. " + "Got status_code '%s'.", + user, + group, + response.status_code, + ) + + response_json = None + if response.headers.get("content-type") == "application/json": + response_json = response.json() + log.error("Received response: %s", response.json()) + + raise PagureError( + ( + "Couldn't remove user '{0}' from group '{1}'\n\n" + "Request to '{2}':\n\n" + "Response:\n" + "{3}\n\n" + "Status code: {4}" + ).format( + user, + group, + api_url, + response_json, + response.status_code, + ) + ) + + log.debug("User '%s' removed from group '%s'", user, group) + + def get_group_members(self, group: str) -> List[str]: + """ + Get members of group. + + Params: + group: Group name + + Returns: + List of users or empty list if group doesn't exists.. + + Raises: + `toddlers.utils.exceptions.PagureError``: When getting members fail. + """ + result = [] + api_url = "{0}/api/0/group/{1}".format(self._pagure_url, group) + headers = self.get_auth_header() + + log.debug("Getting members of group '%s'", group) + response = self._requests_session.get(api_url, headers=headers) + + if response.ok: + if "members" in response.json(): + result = response.json()["members"] + else: + raise PagureError( + "The 'members' parameter is missing in response. " + "JSON response: {0}".format(response.json()) + ) + elif response.status_code == 404: + return result + else: + log.error( + "Error when retrieving members from group '%s'. " + "Got status_code '%s'.", + group, + response.status_code, + ) + + response_json = None + if response.headers.get("content-type") == "application/json": + response_json = response.json() + log.error("Received response: %s", response.json()) + + raise PagureError( + ( + "Couldn't get members of group '{0}'\n\n" + "Request to '{1}:\n\n" + "Response:\n" + "{2}\n\n" + "Status code: {3}" + ).format( + group, + api_url, + response_json, + response.status_code, + ) + ) + + log.debug("Members retrieved for group '%s'", group) + + return result