diff --git a/ferry_cli/__main__.py b/ferry_cli/__main__.py index cbb3a93..62e2ca3 100755 --- a/ferry_cli/__main__.py +++ b/ferry_cli/__main__.py @@ -368,7 +368,7 @@ def run( workflow.init_parser() workflow_params, _ = workflow.parser.parse_known_args(endpoint_args) json_result = workflow.run(self.ferry_api, vars(workflow_params)) # type: ignore - if not dryrun: + if (not dryrun) and json_result: self.handle_output( json.dumps(json_result, indent=4), args.output, debug_level ) diff --git a/ferry_cli/helpers/api.py b/ferry_cli/helpers/api.py index 33b0bc3..34e1885 100755 --- a/ferry_cli/helpers/api.py +++ b/ferry_cli/helpers/api.py @@ -46,7 +46,9 @@ def call_endpoint( ) -> Any: # Create a session object to persist certain parameters across requests if self.dryrun: - print(f"\nWould call endpoint: {self.base_url}{endpoint}") + print( + f"\nWould call endpoint: {self.base_url}{endpoint} with params\n{params}" + ) return None debug = self.debug_level == DebugLevel.DEBUG diff --git a/ferry_cli/helpers/supported_workflows/NewCapabilitySet.py b/ferry_cli/helpers/supported_workflows/NewCapabilitySet.py new file mode 100755 index 0000000..c3d9916 --- /dev/null +++ b/ferry_cli/helpers/supported_workflows/NewCapabilitySet.py @@ -0,0 +1,364 @@ +# pylint: disable=invalid-name,arguments-differ,unused-import +import sys +from typing import Any, List + +try: + from ferry_cli.helpers.api import FerryAPI + from ferry_cli.helpers.auth import DebugLevel + from ferry_cli.helpers.workflows import Workflow +except ImportError: + from helpers.api import FerryAPI # type: ignore + from helpers.auth import DebugLevel # type: ignore + from helpers.workflows import Workflow # type: ignore + + +class NewCapabilitySet(Workflow): + def __init__(self: "NewCapabilitySet") -> None: + self.name = "newCapabilitySet" + self.method = "PUT" + self.description = ( + "Creates a new capability set based on a given role and unix group" + ) + self.params = [ + { + "name": "groupname", + "description": "UNIX group the new capability set will be associated with", + "type": "string", + "required": True, + }, + { + "name": "gid", + "description": "GID of the UNIX group groupname", + "type": int, + "required": True, + }, + { + "name": "unitname", + "description": "Affiliation Unit the new capability set will be associated with", + "type": "string", + "required": True, + }, + { + "name": "fqan", + "description": "FQAN associated with the new capability set", + "type": "string", + "required": True, + }, + { + "name": "setname", + "description": "Name of the new capability set", + "type": "string", + "required": True, + }, + { + "name": "scopes_pattern", + "description": "Scopes of the new capability set", + "type": "string", + "required": True, + }, + { + "name": "mapped_user", + "description": "If the capability set needs to be mapped to a specific user, this is that mapped username", + "type": "string", + "required": False, + }, + { + "name": "token_subject", + "description": ( + "The default will just be setname@fnal.gov, but if the resultant token should have the user UID from FERRY as the subject " + + 'then set this to the string "none"' + ), + "type": "string", + "required": False, + }, + ] + super().__init__() + + def run(self: "NewCapabilitySet", api: "FerryAPI", args: Any) -> Any: # type: ignore # pylint: disable=arguments-differ,too-many-branches,too-many-statements + """Run the workflow to add a new capability set to FERRY""" + if api.dryrun: + print( + "WARNING: This workflow is being run with the --dryrun flag. The exact steps shown here may differ since " + "some of the workflow steps depend on the output of API calls." + ) + + # Note - we don't have explicit dryrun checks here because the FerryAPI class handles that for us + # 1. Create new group in FERRY + try: + self.verify_output( + api, + api.call_endpoint( + "createGroup", + method="PUT", + params={ + "groupname": args["groupname"], + "gid": args["gid"], + "grouptype": "UnixGroup", + }, + ), + ) + except Exception as e: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to create group") + if "groupname already exists" in str(e): + print( + f"Group {args['groupname']} already exists in FERRY. Continuing with the workflow." + ) + else: + raise + + # Check + if not api.dryrun: + try: + response = self.verify_output( + api, + api.call_endpoint( + "getGroupName", + params={"gid": args["gid"]}, + ), + ) + if response["groupname"] != args["groupname"]: + print( + f"Group name {response['groupname']} does not match expected group name {args['groupname']}" + ) + raise ValueError("Group name mismatch") + except Exception: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to verify group creation") + raise + + # 2. Add group to unit + try: + self.verify_output( + api, + api.call_endpoint( + "addGroupToUnit", + method="PUT", + params={ + "groupname": args["groupname"], + "unitname": args["unitname"], + "grouptype": "UnixGroup", + }, + ), + ) + except Exception: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to add group to unit") + raise + + # Check + if not api.dryrun: + try: + response = self.verify_output( + api, + api.call_endpoint( + "getGroupUnits", + params={"groupname": args["groupname"]}, + ), + ) + units = (entry["unitname"] for entry in response) + for unit in units: + if unit == args["unitname"]: + break + else: + raise ValueError( + f"Group {args['groupname']} does not belong to unit {args['unitname']}" + ) + except Exception: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to verify group-unit association") + raise + + # TODO Test this case # pylint: disable=fixme + # 2a. Optional - add mapped user to group + if args.get("mapped_user", ""): + try: + self.verify_output( + api, + api.call_endpoint( + "addUserToGroup", + method="PUT", + params={ + "groupname": args["groupname"], + "username": args["mapped_user"], + "grouptype": "UnixGroup", + }, + ), + ) + except Exception: + if api.debug_level != DebugLevel.QUIET: + print("Failed to add mapped user to group") + raise + # Check + if not api.dryrun: + try: + response = self.verify_output( + api, + api.call_endpoint( + "getGroupMembers", + params={"groupname": args["groupname"]}, + ), + ) + users = (entry["username"] for entry in response) + for user in users: + if user == args["mapped_user"]: + break + else: + raise ValueError( + f"Mapped user {args['mapped_user']} does not belong to group {args['groupname']}" + ) + except Exception: + if api.debug_level != DebugLevel.QUIET: + print("Failed to verify mapped user-group association") + raise + + # 3. Create new FQAN + try: + params = { + "fqan": args["fqan"], + "unitname": args["unitname"], + "groupname": args["groupname"], + } + if args.get("mapped_user"): + params["username"] = args["mapped_user"] + + self.verify_output( + api, + api.call_endpoint( + "createFQAN", + method="PUT", + params=params, + ), + ) + except Exception: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to create FQAN") + raise + + # No Check available for FQAN creation at this time + + # 4. Create capability set + try: + new_cap_set_params = { + "setname": args["setname"], + "pattern": args["scopes_pattern"], + } + if args.get("token_subject", None) is not None: + new_cap_set_params["token_subject"] = args["token_subject"] + + self.verify_output( + api, + api.call_endpoint( + "createCapabilitySet", + method="PUT", + params=new_cap_set_params, + ), + ) + except Exception: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to create capability set") + raise + # Check will be after next step + + # 5. Associate capability set with FQAN + role = self._calculate_role(args["fqan"]) + if not role: + print(f"Failed to calculate role from FQAN {args['fqan']}") + raise ValueError("Role calculation failed") + try: + self.verify_output( + api, + api.call_endpoint( + "addCapabilitySetToFQAN", + method="PUT", + params={ + "setname": args["setname"], + "unitname": args["unitname"], + "role": role, + }, + ), + ) + except Exception: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to associate capability set with FQAN") + raise + + # Check all capability set settings + if not api.dryrun: + try: + response = self.verify_output( + api, + api.call_endpoint( + "getCapabilitySet", + params={"setname": args["setname"]}, + ), + ) + # For some reason, the getCapabilitySet API returns a list, so we need to extract the first element + set_info = response[0] + + # Verify that the capability set name matches the expected name + try: + assert set_info["setname"] == args["setname"] + except AssertionError: + raise ValueError( + f"Capability set name {set_info['setname']} does not match expected name {args['setname']}" + ) + + # Verify that the capability set pattern matches the expected pattern + try: + assert self._check_lists_for_same_elts( + set_info["patterns"], + self.scopes_string_to_list(args["scopes_pattern"]), + ) + except AssertionError: + raise ValueError( + f"Capability set pattern {set_info['patterns']} does not match expected pattern {args['scopes_pattern']}" + ) + + # Verify that the capability set FQAN and role matches the expected FQAN and role + role_entries = (entry for entry in set_info["roles"]) + for entry in role_entries: + if entry["role"] == role: + try: + assert entry["fqan"] == args["fqan"] + except AssertionError: + raise ValueError( + f"Capability set role {entry['role']} does not match expected role {role}" + ) + break # Good case - role and fqan match + else: + raise ValueError( + f"Capability set role does not match expected role {role} or FQAN {args['fqan']} is not found in proper role entry" + ) + except Exception: # pylint: disable=broad-except + if api.debug_level != DebugLevel.QUIET: + print("Failed to verify capability set creation") + raise + print(f"Successfully created capability set {args['setname']}.") + + @staticmethod + def scopes_string_to_list( + scopes_string: str, out_delimiter: str = "," + ) -> List[str]: + """Convert a scopes string to a list of scopes delimited by out_delimiter + e.g. "scope1,scope2" -> ["scope1", "scope2"] + """ + if not scopes_string: + return [] + return scopes_string.split(out_delimiter) + + @staticmethod + def _check_lists_for_same_elts(list1: List[str], list2: List[str]) -> bool: + """Compare two lists for the same elements, regardless of order""" + return sorted(list1) == sorted(list2) + + @staticmethod + def _calculate_role(fqan: str) -> str: + """Calculate the role from the FQAN + Something like /fermilab/Role=Rolename/Capability=NULL -> Rolename + """ + parts = fqan.split("/") + for part in parts: + if part.startswith("Role="): + return part.split("=")[1] + return "" diff --git a/ferry_cli/helpers/supported_workflows/__init__.py b/ferry_cli/helpers/supported_workflows/__init__.py index c573050..0009ff1 100755 --- a/ferry_cli/helpers/supported_workflows/__init__.py +++ b/ferry_cli/helpers/supported_workflows/__init__.py @@ -7,13 +7,16 @@ from ferry_cli.helpers.supported_workflows.GetFilteredGroupInfo import ( GetFilteredGroupInfo, ) + from ferry_cli.helpers.supported_workflows.NewCapabilitySet import NewCapabilitySet except ImportError: from helpers.workflows import Workflow # type: ignore from helpers.supported_workflows.CloneResource import CloneResource # type: ignore from helpers.supported_workflows.GetFilteredGroupInfo import GetFilteredGroupInfo # type: ignore + from helpers.supported_workflows.NewCapabilitySet import NewCapabilitySet # type: ignore SUPPORTED_WORKFLOWS: Mapping[str, Type[Workflow]] = { "cloneResource": CloneResource, "getFilteredGroupInfo": GetFilteredGroupInfo, + "newCapabilitySet": NewCapabilitySet, } diff --git a/ferry_cli/helpers/workflows.py b/ferry_cli/helpers/workflows.py index 9023508..64bdd06 100755 --- a/ferry_cli/helpers/workflows.py +++ b/ferry_cli/helpers/workflows.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -import sys from typing import Any, Dict, List try: @@ -41,9 +40,14 @@ def verify_output(self: "Workflow", api: "FerryAPI", response: Any) -> Any: if api.dryrun: return {} if not response: - print("Failed'") - sys.exit(1) - if "ferry_status" in response and response.get("ferry_status", "") != "success": + print("Failed to verify output") + raise RuntimeError("Empty response from FERRY") + if response.get("ferry_status", "") != "success": + print("Failed to verify output") print(f"{response}") - sys.exit(1) + if "ferry_error" in response: + raise RuntimeError( + "FERRY returned error(s): " + ", ".join(response["ferry_error"]) + ) + raise RuntimeError("FERRY did not return a successful response") return response["ferry_output"] diff --git a/tests/test_NewCapabilitySet.py b/tests/test_NewCapabilitySet.py new file mode 100644 index 0000000..9b60210 --- /dev/null +++ b/tests/test_NewCapabilitySet.py @@ -0,0 +1,186 @@ +import pytest + +from ferry_cli.helpers.api import FerryAPI +from ferry_cli.helpers.auth import Auth +from ferry_cli.helpers.supported_workflows.NewCapabilitySet import NewCapabilitySet + + +@pytest.mark.parametrize( + "args, expected", + [ + ( + { + "groupname": "testgroup", + "gid": 1234, + "unitname": "testunit", + "fqan": "/org/Role=myrole/Capability=NULL", + "setname": "testcapabilityset", + "scopes_pattern": "scope1,scope2", + }, + [ + ( + "Would call endpoint: https://example.com/createGroup with params\n" + + "{'groupname': 'testgroup', 'gid': 1234, 'grouptype': 'UnixGroup'}" + ), + ( + "Would call endpoint: https://example.com/addGroupToUnit with params\n" + + "{'groupname': 'testgroup', 'unitname': 'testunit', 'grouptype': 'UnixGroup'}" + ), + ( + f"Would call endpoint: https://example.com/createFQAN with params\n" + + "{'fqan': '/org/Role=myrole/Capability=NULL', 'unitname': 'testunit', 'groupname': 'testgroup'}" + ), + ( + "Would call endpoint: https://example.com/createCapabilitySet with params\n" + + "{'setname': 'testcapabilityset', 'pattern': 'scope1,scope2'}" + ), + ( + "Would call endpoint: https://example.com/addCapabilitySetToFQAN with params\n" + + "{'setname': 'testcapabilityset', 'unitname': 'testunit', 'role': 'myrole'}" + ), + ], + ), + ( + { + "groupname": "testgroup", + "gid": 1234, + "unitname": "testunit", + "fqan": "/org/Role=myrole/Capability=NULL", + "setname": "testcapabilityset", + "scopes_pattern": "scope1,scope2", + "token_subject": "none", + }, + [ + ( + "Would call endpoint: https://example.com/createGroup with params\n" + + "{'groupname': 'testgroup', 'gid': 1234, 'grouptype': 'UnixGroup'}" + ), + ( + "Would call endpoint: https://example.com/addGroupToUnit with params\n" + + "{'groupname': 'testgroup', 'unitname': 'testunit', 'grouptype': 'UnixGroup'}" + ), + ( + f"Would call endpoint: https://example.com/createFQAN with params\n" + + "{'fqan': '/org/Role=myrole/Capability=NULL', 'unitname': 'testunit', 'groupname': 'testgroup'}" + ), + ( + "Would call endpoint: https://example.com/createCapabilitySet with params\n" + + "{'setname': 'testcapabilityset', 'pattern': 'scope1,scope2', 'token_subject': 'none'}" + ), + ( + "Would call endpoint: https://example.com/addCapabilitySetToFQAN with params\n" + + "{'setname': 'testcapabilityset', 'unitname': 'testunit', 'role': 'myrole'}" + ), + ], + ), + ( + { + "groupname": "testgroup", + "mapped_user": "testuser", + "gid": 1234, + "unitname": "testunit", + "fqan": "/org/Role=myrole/Capability=NULL", + "setname": "testcapabilityset", + "scopes_pattern": "scope1,scope2", + }, + [ + ( + "Would call endpoint: https://example.com/createGroup with params\n" + + "{'groupname': 'testgroup', 'gid': 1234, 'grouptype': 'UnixGroup'}" + ), + ( + "Would call endpoint: https://example.com/addGroupToUnit with params\n" + + "{'groupname': 'testgroup', 'unitname': 'testunit', 'grouptype': 'UnixGroup'}" + ), + ( + "Would call endpoint: https://example.com/addUserToGroup with params\n" + + "{'groupname': 'testgroup', 'username': 'testuser', 'grouptype': 'UnixGroup'}" + ), + ( + f"Would call endpoint: https://example.com/createFQAN with params\n" + + "{'fqan': '/org/Role=myrole/Capability=NULL', 'unitname': 'testunit', 'groupname': 'testgroup', 'username': 'testuser'}" + ), + ( + "Would call endpoint: https://example.com/createCapabilitySet with params\n" + + "{'setname': 'testcapabilityset', 'pattern': 'scope1,scope2'}" + ), + ( + "Would call endpoint: https://example.com/addCapabilitySetToFQAN with params\n" + + "{'setname': 'testcapabilityset', 'unitname': 'testunit', 'role': 'myrole'}" + ), + ], + ), + ], +) +@pytest.mark.unit +def test_NewCapabiilitySet_run(args, expected, capsys): + api = FerryAPI( + base_url="https://example.com/", + authorizer=Auth(), + dryrun=True, + ) + + NewCapabilitySet().run( + api=api, + args=args, + ) + + captured = capsys.readouterr() + for elt in expected: + assert elt in captured.out + + +@pytest.mark.parametrize( + "scopes_string, out_delimiter, expected", + [ + ("scope1,scope2", ",", ["scope1", "scope2"]), + ("scope1 scope2", " ", ["scope1", "scope2"]), + ("scope1, scope2", ", ", ["scope1", "scope2"]), + ( + "scope,1,scope2", + ",", + ["scope", "1", "scope2"], + ), # This is a malformed scope, but we want to test that it is handled correctly + ], +) +@pytest.mark.unit +def test_scopes_string_to_list(scopes_string, out_delimiter, expected): + assert ( + NewCapabilitySet.scopes_string_to_list(scopes_string, out_delimiter) == expected + ) + + +@pytest.mark.parametrize( + "list1, list2, expected", + [ + (["a", "b", "c"], ["c", "b", "a"], True), + (["a", "b", "c"], ["a", "b"], False), + (["a", "b", "c"], ["d", "e", "f"], False), + ([], [], True), + (["a"], ["a"], True), + (["a"], [], False), + ], +) +@pytest.mark.unit +def test_check_lists_for_same_elts(list1, list2, expected) -> bool: + assert NewCapabilitySet._check_lists_for_same_elts(list1, list2) == expected + + +@pytest.mark.parametrize( + "fqan, expected", + [ + ("/org/exp1/Role=myrole/Capability=NULL", "myrole"), + ("/org/Role=myrole/Capability=NULL", "myrole"), + ("/org/role=myrole/Capability=NULL", ""), + ("/org/myrole/Capability=NULL", ""), + ( + "/org/Role=my/role/Capability=NULL", + "my", + ), # This is a malformed FQAN, but we want to test that it is handled correctly + ("randomstring", ""), + ("", ""), + ], +) +@pytest.mark.unit +def test_calculate_role(fqan, expected) -> str: + assert NewCapabilitySet._calculate_role(fqan) == expected