diff --git a/plugins/module_utils/cdp_client.py b/plugins/module_utils/cdp_client.py index 54f138d3..a47c32a3 100644 --- a/plugins/module_utils/cdp_client.py +++ b/plugins/module_utils/cdp_client.py @@ -30,7 +30,7 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from email.utils import formatdate from typing import Any, Dict, Optional, List, Tuple, Union -from urllib.parse import urlencode, urlparse +from urllib.parse import urlparse from ansible.module_utils.urls import fetch_url @@ -230,7 +230,7 @@ def __init__(self, msg: str, status: Optional[int] = None): self.status = status -class RestClient: +class CdpClient: """Abstract base class for CDP REST API clients.""" def __init__(self, default_page_size: int = 100): @@ -244,7 +244,7 @@ def __init__(self, default_page_size: int = 100): # Abstract HTTP methods that must be implemented by subclasses @abc.abstractmethod - def _get( + def get( self, path: str, params: Optional[Dict[str, Any]] = None, @@ -253,27 +253,33 @@ def _get( pass @abc.abstractmethod - def _post( + def post( self, path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP POST request.""" pass @abc.abstractmethod - def _put( + def put( self, path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP PUT request.""" pass @abc.abstractmethod - def _delete(self, path: str) -> Dict[str, Any]: + def delete( + self, + path: str, + squelch: Dict[int, Any] = {}, + ) -> Dict[str, Any]: """Execute HTTP DELETE request.""" pass @@ -283,7 +289,7 @@ def paginated(default_page_size=100): Decorator to handle automatic pagination for CDP API methods. Usage: - @RestClient.paginated() + @CdpClient.paginated() def some_api_method(self, param1, param2, startingToken=None, pageSize=None): # Method implementation pass @@ -378,52 +384,7 @@ def wrapper(self, *args, **kwargs): return decorator -class CdpClient: - """CDP client that uses a RestClient instance to delegate HTTP methods.""" - - def __init__( - self, - api_client: RestClient, - default_page_size: int = 100, - ): - """ - Initialize Delegated CDP client. - - Args: - api_client: CdpClient instance to delegate HTTP methods to - default_page_size: Default page size for paginated requests - """ - self.default_page_size = default_page_size - self.api_client: RestClient = api_client - - def get( - self, - path: str, - params: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - return self.api_client._get(path, params) - - def post( - self, - path: str, - data: Optional[Dict[str, Any]] = None, - json_data: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - return self.api_client._post(path, data, json_data) - - def put( - self, - path: str, - data: Optional[Dict[str, Any]] = None, - json_data: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - return self.api_client._put(path, data, json_data) - - def delete(self, path: str) -> Dict[str, Any]: - return self.api_client._delete(path) - - -class AnsibleCdpClient(RestClient): +class AnsibleCdpClient(CdpClient): """Ansible-based CDP client using native Ansible HTTP methods.""" def __init__( @@ -477,6 +438,7 @@ def _make_request( data: Optional[Union[Dict[str, Any], List[Any]]] = None, json_data: Optional[Union[Dict[str, Any], List[Any]]] = None, max_retries: int = 3, + squelch: Dict[int, Any] = {}, ) -> Any: """ Make HTTP request with retry logic using Ansible's fetch_url. @@ -488,6 +450,7 @@ def _make_request( data: Form data json_data: JSON data max_retries: Maximum number of retry attempts + squelch: Dictionary of HTTP status codes to squelch with default return values Returns: Response data as dictionary or None for 204 responses @@ -556,6 +519,12 @@ def _make_request( if status_code == 403: raise CdpError(f"Forbidden access to {path}", status=403) + if status_code in squelch: + self.module.warn( + f"Squelched error {status_code} for {url}", + ) + return squelch[status_code] + # Handle success responses if 200 <= status_code < 300: # 204 No Content - return None @@ -627,7 +596,7 @@ def _make_request( except Exception as e: self.module.fail_json(msg=str(e)) - def _get( + def get( self, path: str, params: Optional[Dict[str, Any]] = None, @@ -635,24 +604,38 @@ def _get( """Execute HTTP GET request.""" return self._make_request("GET", path, params=params) - def _post( + def post( self, path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP POST request.""" - return self._make_request("POST", path, data=data, json_data=json_data) + return self._make_request( + "POST", + path, + data=data, + json_data=json_data, + squelch=squelch, + ) - def _put( + def put( self, path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP PUT request.""" - return self._make_request("PUT", path, data=data, json_data=json_data) + return self._make_request( + "PUT", + path, + data=data, + json_data=json_data, + squelch=squelch, + ) - def _delete(self, path: str) -> Dict[str, Any]: + def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: """Execute HTTP DELETE request.""" - return self._make_request("DELETE", path) + return self._make_request("DELETE", path, squelch=squelch) diff --git a/plugins/module_utils/cdp_consumption.py b/plugins/module_utils/cdp_consumption.py index 867fae1c..610cc35b 100644 --- a/plugins/module_utils/cdp_consumption.py +++ b/plugins/module_utils/cdp_consumption.py @@ -21,24 +21,23 @@ from typing import Any, Dict, Optional from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, CdpClient, ) -class CdpConsumptionClient(CdpClient): +class CdpConsumptionClient: """CDP Consumption API client.""" - def __init__(self, api_client: RestClient): + def __init__(self, api_client: CdpClient): """ Initialize CDP Consumption client. Args: - api_client: RestClient instance for managing HTTP method calls + api_client: CdpClient instance for managing HTTP method calls """ - super().__init__(api_client=api_client) + self.api_client = api_client - @RestClient.paginated() + @CdpClient.paginated() def list_compute_usage_records( self, from_timestamp: str, @@ -69,7 +68,7 @@ def list_compute_usage_records( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/consumption/listComputeUsageRecords", json_data=json_data, ) diff --git a/plugins/module_utils/cdp_iam.py b/plugins/module_utils/cdp_iam.py index 37280400..d069071b 100644 --- a/plugins/module_utils/cdp_iam.py +++ b/plugins/module_utils/cdp_iam.py @@ -21,24 +21,237 @@ from typing import Any, Dict, List, Optional from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, CdpClient, ) -class CdpIamClient(CdpClient): +class CdpIamClient: """CDP IAM API client.""" - def __init__(self, api_client: RestClient): + def __init__(self, api_client: CdpClient): """ Initialize CDP IAM client. Args: - api_client: RestClient instance for managing HTTP method calls + api_client: CdpClient instance for managing HTTP method calls """ - super().__init__(api_client=api_client) + self.api_client = api_client - @RestClient.paginated() + def _is_machine_user(self, user_crn: str) -> bool: + """Check if a user CRN represents a machine user.""" + return ":machineUser:" in user_crn + + def get_group_details(self, group_name: str) -> Optional[Dict[str, Any]]: + """ + Get complete group information including members, roles, and resource assignments. + + This method makes multiple API calls to assemble a comprehensive group profile: + - Basic group information (from list_groups) + - Group members (users and machine users) + - Assigned roles + - Assigned resource roles + + Returns: + Complete group information dict, or None if group doesn't exist + """ + groups = self.list_groups(group_names=[group_name]).get("groups", []) + # all_groups_response = self.list_groups() + # all_groups = all_groups_response.get("groups", []) + # groups = [g for g in all_groups if g.get("groupName") == group_name] + + if not groups: + return None + + group_info = groups[0] + + # Get group members (users and machine users) + members_response = self.list_group_members(group_name=group_name) + members = members_response.get("memberCrns", []) + + # Get assigned roles + roles_response = self.list_group_assigned_roles(group_name=group_name) + roles = roles_response.get("roleCrns", []) + + # Get assigned resource roles + resource_roles_response = self.list_group_assigned_resource_roles( + group_name=group_name, + ) + resource_assignments = resource_roles_response.get("resourceAssignments", []) + + # Build complete group object + return { + "groupName": group_info.get("groupName"), + "crn": group_info.get("crn"), + "creationDate": group_info.get("creationDate"), + "syncMembershipOnUserLogin": group_info.get("syncMembershipOnUserLogin"), + "members": members, + "roles": roles, + "resourceAssignments": resource_assignments, + } + + def manage_group_users( + self, + group_name: str, + current_members: List[str], + desired_users: List[str], + purge: bool = False, + ) -> bool: + """ + Manage group membership (add/remove users and machine users). + + Args: + group_name: The name of the group + current_members: List of current member CRNs + desired_users: List of desired user CRNs + purge: If True, remove users not in desired list + + Returns: + True if changes were made, False otherwise + """ + changed = False + + if purge: + # Remove all users not in desired list + users_to_remove = [ + user for user in current_members if user not in desired_users + ] + for user_crn in users_to_remove: + if self._is_machine_user(user_crn): + self.remove_machine_user_from_group( + machine_user_name=user_crn, + group_name=group_name, + ) + else: + self.remove_user_from_group(user_id=user_crn, group_name=group_name) + changed = True + + # Add missing users + users_to_add = [user for user in desired_users if user not in current_members] + for user_crn in users_to_add: + if self._is_machine_user(user_crn): + self.add_machine_user_to_group( + machine_user_name=user_crn, + group_name=group_name, + ) + else: + self.add_user_to_group(user_id=user_crn, group_name=group_name) + changed = True + + return changed + + def manage_group_roles( + self, + group_name: str, + current_roles: List[str], + desired_roles: List[str], + purge: bool = False, + ) -> bool: + """ + Manage group role assignments. + + Args: + group_name: The name of the group + current_roles: List of current role CRNs + desired_roles: List of desired role CRNs + purge: If True, remove roles not in desired list + + Returns: + True if changes were made, False otherwise + """ + changed = False + + if purge: + # Remove all roles not in desired list + roles_to_remove = [ + role for role in current_roles if role not in desired_roles + ] + for role_crn in roles_to_remove: + self.unassign_group_role(group_name=group_name, role=role_crn) + changed = True + + # Add missing roles + roles_to_add = [role for role in desired_roles if role not in current_roles] + for role_crn in roles_to_add: + self.assign_group_role(group_name=group_name, role=role_crn) + changed = True + + return changed + + def manage_group_resource_roles( + self, + group_name: str, + current_assignments: List[Dict[str, str]], + desired_assignments: List[Dict[str, str]], + purge: bool = False, + ) -> bool: + """ + Manage group resource role assignments. + + Args: + group_name: The name of the group + current_assignments: List of current resource role assignments + desired_assignments: List of desired resource role assignments + purge: If True, remove assignments not in desired list + + Returns: + True if changes were made, False otherwise + """ + changed = False + + # Normalize current assignments for comparison + current_normalized = [ + { + "resource": a.get("resourceCrn"), + "role": a.get("resourceRoleCrn"), + } + for a in current_assignments + ] + + # Normalize desired assignments + desired_normalized = [ + { + "resource": a.get("resource") or a.get("resourceCrn"), + "role": a.get("role") or a.get("resourceRoleCrn"), + } + for a in desired_assignments + ] + + if purge: + # Remove all assignments not in desired list + assignments_to_remove = [ + a for a in current_normalized if a not in desired_normalized + ] + for assignment in assignments_to_remove: + self.unassign_group_resource_role( + group_name=group_name, + resource_crn=assignment[ + "resource" + ], # pyright: ignore[reportArgumentType] + resource_role_crn=assignment[ + "role" + ], # pyright: ignore[reportArgumentType] + ) + changed = True + + # Add missing assignments + assignments_to_add = [ + a for a in desired_normalized if a not in current_normalized + ] + for assignment in assignments_to_add: + self.assign_group_resource_role( + group_name=group_name, + resource_crn=assignment[ + "resource" + ], # pyright: ignore[reportArgumentType] + resource_role_crn=assignment[ + "role" + ], # pyright: ignore[reportArgumentType] + ) + changed = True + + return changed + + @CdpClient.paginated() def list_groups( self, group_names: Optional[List[str]] = None, @@ -68,7 +281,738 @@ def list_groups( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listGroups", json_data=json_data, + squelch={404: {}}, + ) + + @CdpClient.paginated() + def list_users( + self, + user_ids: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List IAM users with automatic pagination. + + Args: + user_ids: Optional list of user IDs or CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing users list + """ + json_data: Dict[str, Any] = {} + + # Add user IDs filter if provided + if user_ids is not None: + json_data["userIds"] = user_ids + + # Add pagination parameters if provided + # Note: IAM API uses "startingToken" for requests, but decorator uses "pageToken" + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listUsers", + json_data=json_data, + ) + + def get_user(self, user_id: Optional[str] = None) -> Dict[str, Any]: + """ + Get information about a user. + + Args: + user_id: Optional user ID or CRN. If not provided, gets the current user. + + Returns: + Response containing user information + """ + json_data: Dict[str, Any] = {} + + # Add user ID if provided + if user_id is not None: + json_data["userId"] = user_id + + response = self.api_client.post( + "/api/v1/iam/getUser", + json_data=json_data, + ) + + return response.get("user", {}) + + @CdpClient.paginated() + def list_group_assigned_resource_roles( + self, + group_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles assigned to a group with automatic pagination. + + Args: + group_name: Group name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignments + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listGroupAssignedResourceRoles", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_group_assigned_roles( + self, + group_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles assigned to a group with automatic pagination. + + Args: + group_name: Group name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing role CRNs + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listGroupAssignedRoles", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_group_members( + self, + group_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List members of a group with automatic pagination. + + Args: + group_name: Group name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing member CRNs + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listGroupMembers", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_groups_for_machine_user( + self, + machine_user_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List groups for a machine user with automatic pagination. + + Args: + machine_user_name: Machine user name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing group CRNs + """ + json_data: Dict[str, Any] = {"machineUserName": machine_user_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listGroupsForMachineUser", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_groups_for_user( + self, + user_id: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List groups for a user with automatic pagination. + + Args: + user_id: User ID or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing group CRNs + """ + json_data: Dict[str, Any] = {"userId": user_id} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listGroupsForUser", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_machine_user_assigned_resource_roles( + self, + machine_user_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles assigned to a machine user with automatic pagination. + + Args: + machine_user_name: Machine user name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignments + """ + json_data: Dict[str, Any] = {"machineUserName": machine_user_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listMachineUserAssignedResourceRoles", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_machine_user_assigned_roles( + self, + machine_user_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles assigned to a machine user with automatic pagination. + + Args: + machine_user_name: Machine user name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing role CRNs + """ + json_data: Dict[str, Any] = {"machineUserName": machine_user_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listMachineUserAssignedRoles", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_machine_users( + self, + machine_user_names: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List machine users with automatic pagination. + + Args: + machine_user_names: Optional list of machine user names or CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing machine users list + """ + json_data: Dict[str, Any] = {} + + if machine_user_names is not None: + json_data["machineUserNames"] = machine_user_names + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listMachineUsers", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_resource_assignees( + self, + resource_crn: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource assignees and their resource roles with automatic pagination. + + Args: + resource_crn: Resource CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignees + """ + json_data: Dict[str, Any] = {"resourceCrn": resource_crn} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listResourceAssignees", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_resource_roles( + self, + resource_role_names: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles with automatic pagination. + + Args: + resource_role_names: Optional list of resource role CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource roles list + """ + json_data: Dict[str, Any] = {} + + if resource_role_names is not None: + json_data["resourceRoleNames"] = resource_role_names + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listResourceRoles", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_roles( + self, + role_names: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles with automatic pagination. + + Args: + role_names: Optional list of role names or CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing roles list + """ + json_data: Dict[str, Any] = {} + + if role_names is not None: + json_data["roleNames"] = role_names + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listRoles", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_user_assigned_resource_roles( + self, + user: Optional[str] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles assigned to a user with automatic pagination. + + Args: + user: Optional user CRN or ID. If not provided, defaults to the user making the request. + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignments + """ + json_data: Dict[str, Any] = {} + + if user is not None: + json_data["user"] = user + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listUserAssignedResourceRoles", + json_data=json_data, + ) + + @CdpClient.paginated() + def list_user_assigned_roles( + self, + user: Optional[str] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles assigned to a user with automatic pagination. + + Args: + user: Optional user CRN or ID. If not provided, defaults to the user making the request. + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing role CRNs + """ + json_data: Dict[str, Any] = {} + + if user is not None: + json_data["user"] = user + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.api_client.post( + "/api/v1/iam/listUserAssignedRoles", + json_data=json_data, + ) + + # Group Lifecycle Management Methods + + def create_group( + self, + group_name: str, + sync_membership_on_user_login: Optional[bool] = None, + ) -> Dict[str, Any]: + """ + Create a new IAM group. + + Args: + group_name: The name of the group. Must be unique. + sync_membership_on_user_login: Whether group membership is synced when a user logs in. + Defaults to True if not specified. + + Returns: + Response containing the created group information + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if sync_membership_on_user_login is not None: + json_data["syncMembershipOnUserLogin"] = sync_membership_on_user_login + + return self.api_client.post( + "/api/v1/iam/createGroup", + json_data=json_data, + ) + + def delete_group(self, group_name: str) -> Dict[str, Any]: + """ + Delete an IAM group. + + Args: + group_name: The name or CRN of the group to delete + + Returns: + Response confirming deletion + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + return self.api_client.post( + "/api/v1/iam/deleteGroup", + json_data=json_data, + squelch={404: {}}, + ) + + def update_group( + self, + group_name: str, + sync_membership_on_user_login: Optional[bool] = None, + ) -> Dict[str, Any]: + """ + Update an IAM group. + + Args: + group_name: The name or CRN of the group to update + sync_membership_on_user_login: Whether group membership is synced when a user logs in. + Can be omitted if no update is required. + + Returns: + Response containing the updated group information + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if sync_membership_on_user_login is not None: + json_data["syncMembershipOnUserLogin"] = sync_membership_on_user_login + + return self.api_client.post( + "/api/v1/iam/updateGroup", + json_data=json_data, + ) + + # Group Membership Management Methods + + def add_user_to_group(self, group_name: str, user_id: str) -> Dict[str, Any]: + """ + Add a user to a group. + + Args: + group_name: The name or CRN of the group + user_id: The ID or CRN of the user to add to the group + + Returns: + Response confirming the user was added to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "userId": user_id, + } + + return self.api_client.post( + "/api/v1/iam/addUserToGroup", + json_data=json_data, + ) + + def add_machine_user_to_group( + self, + group_name: str, + machine_user_name: str, + ) -> Dict[str, Any]: + """ + Add a machine user to a group. + + Args: + group_name: The name or CRN of the group + machine_user_name: The name or CRN of the machine user to add to the group + + Returns: + Response confirming the machine user was added to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "machineUserName": machine_user_name, + } + + return self.api_client.post( + "/api/v1/iam/addMachineUserToGroup", + json_data=json_data, + ) + + def remove_user_from_group(self, group_name: str, user_id: str) -> Dict[str, Any]: + """ + Remove a user from a group. + + Args: + group_name: The name or CRN of the group + user_id: The ID or CRN of the user to remove from the group + + Returns: + Response confirming the user was removed from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "userId": user_id, + } + + return self.api_client.post( + "/api/v1/iam/removeUserFromGroup", + json_data=json_data, + ) + + def remove_machine_user_from_group( + self, + group_name: str, + machine_user_name: str, + ) -> Dict[str, Any]: + """ + Remove a machine user from a group. + + Args: + group_name: The name or CRN of the group + machine_user_name: The name or CRN of the machine user to remove from the group + + Returns: + Response confirming the machine user was removed from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "machineUserName": machine_user_name, + } + + return self.api_client.post( + "/api/v1/iam/removeMachineUserFromGroup", + json_data=json_data, + ) + + # Group Role Assignment Methods + + def assign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: + """ + Assign a role to a group. + + Args: + group_name: The group name or CRN + role: The role name or CRN to assign + + Returns: + Response confirming the role was assigned to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "role": role, + } + + return self.api_client.post( + "/api/v1/iam/assignGroupRole", + json_data=json_data, + ) + + def assign_group_resource_role( + self, + group_name: str, + resource_crn: str, + resource_role_crn: str, + ) -> Dict[str, Any]: + """ + Assign a resource role to a group. + + Args: + group_name: The group name or CRN + resource_crn: The resource CRN for which the resource role rights are granted + resource_role_crn: The CRN of the resource role being assigned + + Returns: + Response confirming the resource role was assigned to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "resourceCrn": resource_crn, + "resourceRoleCrn": resource_role_crn, + } + + return self.api_client.post( + "/api/v1/iam/assignGroupResourceRole", + json_data=json_data, + ) + + def unassign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: + """ + Unassign a role from a group. + + Args: + group_name: The group name or CRN + role: The role name or CRN to unassign + + Returns: + Response confirming the role was unassigned from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "role": role, + } + + return self.api_client.post( + "/api/v1/iam/unassignGroupRole", + json_data=json_data, + ) + + def unassign_group_resource_role( + self, + group_name: str, + resource_crn: str, + resource_role_crn: str, + ) -> Dict[str, Any]: + """ + Unassign a resource role from a group. + + Args: + group_name: The group name or CRN + resource_crn: The CRN of the resource for which the resource role rights will be unassigned + resource_role_crn: The CRN of the resource role to unassign + + Returns: + Response confirming the resource role was unassigned from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "resourceCrn": resource_crn, + "resourceRoleCrn": resource_role_crn, + } + + return self.api_client.post( + "/api/v1/iam/unassignGroupResourceRole", + json_data=json_data, ) diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index eb497c05..aff9cf48 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -215,7 +215,6 @@ def __init__( self.endpoint_region: str = region # NOTE: If endpoint is not provided, construct the endpoint parameter from the region - # NOTE: IAM endpoints for us-west-1 need to be explicitly set to iamapi.us-west-1.altus.cloudera.com if self.endpoint is None: self.endpoint = f"https://api.{self.endpoint_region}.cdp.cloudera.com" @@ -266,11 +265,11 @@ def get_param(self, param, default=None) -> Any: return default @abc.abstractmethod - def process(self): + def process(self) -> None: """Abstract method that Service modules must implement to perform their logic.""" pass - def execute(self): + def execute(self) -> None: """Execute the process method and capture logging output.""" try: # Call the abstract process method diff --git a/plugins/modules/iam_group.py b/plugins/modules/iam_group.py index ebd2917f..3a57b9b3 100644 --- a/plugins/modules/iam_group.py +++ b/plugins/modules/iam_group.py @@ -25,9 +25,8 @@ author: - "Webster Mudge (@wmudge)" - "Dan Chaffelson (@chaffelson)" + - "Ronald Suplina (@rsuplina)" version_added: "1.0.0" -requirements: - - cdpy options: name: description: @@ -63,6 +62,7 @@ required: True aliases: - resourceCrn + - resource_crn role: description: - The resource role CRN to be assigned. @@ -70,6 +70,7 @@ required: True aliases: - resourceRoleCrn + - resource_role_crn roles: description: - A single role or list of roles assigned to the group. @@ -96,16 +97,17 @@ aliases: - sync_membership - sync_on_login + - sync_membership_on_user_login users: description: - A single user or list of users assigned to the group. + - Users can be regular users or machine users. - The user can be either the name or CRN. type: list elements: str required: False extends_documentation_fragment: - - cloudera.cloud.cdp_sdk_options - - cloudera.cloud.cdp_auth_options + - cloudera.cloud.cdp_client """ EXAMPLES = r""" @@ -169,8 +171,8 @@ returned: on success type: str sample: example-01 - users: - description: List of User CRNs which are members of the group. + members: + description: List of member CRNs (users and machine users) which are members of the group. returned: on success type: list elements: str @@ -179,8 +181,8 @@ returned: on success type: list elements: str - resource_roles: - description: List of Resource-to-Role assignments, by CRN, that are associated with the group. + resourceAssignments: + description: List of Resource-to-Role assignments that are associated with the group. returned: on success type: list elements: dict @@ -190,7 +192,7 @@ returned: on success type: str resourceRoleCrn: - description: The CRN of the CDP Role. + description: The CRN of the resource role. returned: on success type: str syncMembershipOnUserLogin: @@ -209,212 +211,156 @@ elements: str """ -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_common import CdpModule +from typing import Any + +from ansible_collections.cloudera.cloud.plugins.module_utils.common import ( + ServicesModule, +) +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( + CdpIamClient, +) + + +class IAMGroup(ServicesModule): + def __init__(self): + super().__init__( + argument_spec=dict( + state=dict( + required=False, + type="str", + choices=["present", "absent"], + default="present", + ), + name=dict(required=True, type="str", aliases=["group_name"]), + sync=dict( + required=False, + type="bool", + default=True, + aliases=["sync_membership", "sync_membership_on_user_login"], + ), + users=dict(required=False, type="list", elements="str"), + roles=dict(required=False, type="list", elements="str"), + resource_roles=dict( + required=False, + type="list", + elements="dict", + options=dict( + resource=dict( + required=True, + type="str", + aliases=["resource_crn"], + ), + role=dict( + required=True, + type="str", + aliases=["resource_role_crn"], + ), + ), + ), + purge=dict(required=False, type="bool", default=False), + ), + supports_check_mode=True, + ) + # Set parameters + self.state = self.get_param("state") + self.name = self.get_param("name") + self.sync = self.get_param("sync") + self.users = self.get_param("users") + self.roles = self.get_param("roles") + self.resource_roles = self.get_param("resource_roles") + self.purge = self.get_param("purge") -class IAMGroup(CdpModule): - def __init__(self, module): - super(IAMGroup, self).__init__(module) + # Initialize return values + self.group = {} + self.changed = False - # Set Variables - self.state = self._get_param("state") - self.name = self._get_param("name") - self.sync = self._get_param("sync") - self.users = self._get_param("users") - self.roles = self._get_param("roles") - self.resource_roles = self._get_param("resource_roles") - self.purge = self._get_param("purge") + # Initialize client + self.client = CdpIamClient(api_client=self.api_client) - # Initialize the return values - self.info = dict() + def process(self): + current_group = self.client.get_group_details(group_name=self.name) - # Execute logic process - self.process() + # Delete + if self.state == "absent": + if current_group: + if not self.module.check_mode: + self.client.delete_group(group_name=self.name) + self.changed = True - @CdpModule._Decorators.process_debug - def process(self): - existing = self._retrieve_group() - if existing is None: - if self.state == "present": + if self.state == "present": + # Create + if not current_group: + if not self.module.check_mode: + response = self.client.create_group( + group_name=self.name, + sync_membership_on_user_login=self.sync, + ) + self.group = response.get("group", {}) + current_group = self.client.get_group_details(group_name=self.name) self.changed = True - self.cdpy.iam.create_group(self.name, self.sync) - if self.users: - for user in self.users: - self.cdpy.iam.add_group_user(self.name, user) - if self.roles: - for role in self.roles: - self.cdpy.iam.assign_group_role(self.name, role) - if self.resource_roles: - for assignment in self.resource_roles: - self.cdpy.iam.assign_group_resource_role( - self.name, - assignment["resource"], - assignment["role"], - ) - self.info = self._retrieve_group() - else: - if self.state == "present": - if ( - self.sync is not None - and existing["syncMembershipOnUserLogin"] != self.sync - ): - self.changed = True - self.cdpy.iam.update_group(self.name, self.sync) - if self.users is not None: - # If an empty user list, don't normalize - normalized_users = ( - self.cdpy.iam.gather_users(self.users) if self.users else list() + # Reconcile + if not self.module.check_mode and current_group: + + if self.sync != current_group.get("syncMembershipOnUserLogin"): + self.client.update_group( + group_name=self.name, + sync_membership_on_user_login=self.sync, ) - new_users = [ - user - for user in normalized_users - if user not in existing["users"] - ] - for user in new_users: + self.changed = True + + if self.users is not None or self.purge: + if self.client.manage_group_users( + group_name=self.name, + current_members=current_group.get("members", []), + desired_users=self.users or [], + purge=self.purge, + ): self.changed = True - self.cdpy.iam.add_group_user(self.name, user) - if self.purge: - stale_users = [ - user - for user in existing["users"] - if user not in normalized_users - ] - for user in stale_users: - self.changed = True - self.cdpy.iam.remove_group_user(self.name, user) - - if self.roles is not None: - new_roles = [ - role for role in self.roles if role not in existing["roles"] - ] - for role in new_roles: + + if self.roles is not None or self.purge: + if self.client.manage_group_roles( + group_name=self.name, + current_roles=current_group.get("roles", []), + desired_roles=self.roles or [], + purge=self.purge, + ): self.changed = True - self.cdpy.iam.assign_group_role(self.name, role) - if self.purge: - stale_roles = [ - role for role in existing["roles"] if role not in self.roles - ] - for role in stale_roles: - self.changed = True - self.cdpy.iam.unassign_group_role(self.name, role) - - if self.resource_roles is not None: - new_assignments = self._new_assignments(existing["resource_roles"]) - for assignment in new_assignments: + + if self.resource_roles is not None or self.purge: + if self.client.manage_group_resource_roles( + group_name=self.name, + current_assignments=current_group.get( + "resourceAssignments", + [], + ), + desired_assignments=(self.resource_roles or []), + purge=self.purge, + ): self.changed = True - self.cdpy.iam.assign_group_resource_role( - self.name, - assignment["resource"], - assignment["role"], - ) - if self.purge: - stale_assignments = self._stale_assignments( - existing["resource_roles"], - ) - for assignment in stale_assignments: - self.changed = True - self.cdpy.iam.unassign_group_resource_role( - self.name, - assignment["resource"], - assignment["role"], - ) - - if self.changed: - self.info = self._retrieve_group() - else: - self.info = existing - - elif self.state == "absent": - self.changed = True - self.cdpy.iam.delete_group(self.name) - - def _retrieve_group(self): - # TODO: What does gather_groups need? - group_list = self.cdpy.iam.gather_groups(self.name) - if group_list: - return group_list[0] - else: - return None - - def _new_assignments(self, existing_assignments): - new_assignments = [] - resource_dict = dict() - for existing in existing_assignments: - if existing["resourceCrn"] in resource_dict: - resource_dict[existing["resourceCrn"]].add(existing["resourceRoleCrn"]) - else: - resource_dict[existing["resourceCrn"]] = {existing["resourceRoleCrn"]} - for assignment in self.resource_roles: - if ( - assignment["resource"] not in resource_dict - or assignment["role"] not in resource_dict[assignment["resource"]] - ): - new_assignments.append(assignment) - return new_assignments - - def _stale_assignments(self, existing_assignments): - stale_assignments = [] - resource_dict = dict() - for assignment in self.resource_roles: - if assignment["resource"] in resource_dict: - resource_dict[assignment["resource"]].add(assignment["role"]) + + if self.changed and not self.module.check_mode: + self.group = self.client.get_group_details(group_name=self.name) else: - resource_dict[assignment["resource"]] = {assignment["role"]} - for existing in existing_assignments: - if ( - existing["resourceCrn"] not in resource_dict - or existing["resourceRoleCrn"] - not in resource_dict[existing["resourceCrn"]] - ): - stale_assignments.append(existing) - return stale_assignments + self.group = current_group def main(): - module = AnsibleModule( - argument_spec=CdpModule.argument_spec( - state=dict( - required=False, - type="str", - choices=["present", "absent"], - default="present", - ), - name=dict(required=True, type="str", aliases=["group_name"]), - sync=dict( - required=False, - type="bool", - aliases=["sync_membership", "sync_on_login"], - ), - users=dict(required=False, type="list", elements="str"), - roles=dict(required=False, type="list", elements="str"), - resource_roles=dict( - required=False, - type="list", - elements="dict", - options=dict( - resource=dict(required=True, type="str", aliases=["resourceCrn"]), - role=dict(required=True, type="str", aliases=["resourceRoleCrn"]), - ), - aliases=["assignments"], - ), - purge=dict(required=False, type="bool", default=False, aliases=["replace"]), - ), - supports_check_mode=True, - ) - - result = IAMGroup(module) + result = IAMGroup() - output = dict( + output: dict[str, Any] = dict( changed=result.changed, - group=result.info, + group=result.group, ) - if result.debug: - output.update(sdk_out=result.log_out, sdk_out_lines=result.log_lines) + if result.debug_log: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines, + ) - module.exit_json(**output) + result.module.exit_json(**output) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 426a8b37..d0c5dbdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ filterwarnings = [ markers = [ "integration_api: marks tests as integration tests using CDP API credentials", "integration_token: marks tests as integration tests using CDP token credentials", + "slow: marks tests as slow tests", ] [build-system] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 809c8905..748ff420 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -14,6 +14,25 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json + +from email.utils import formatdate +from functools import wraps +from typing import Any, Dict +from urllib.parse import urlencode +from urllib.error import HTTPError +from http.client import HTTPResponse + +from ansible.module_utils.urls import Request + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + make_signature_header, +) + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + CdpClient, +) + class AnsibleFailJson(Exception): """Exception class to be raised by module.fail_json and caught by the test case""" @@ -36,3 +55,152 @@ def __init__(self, kwargs): def __getattr__(self, attr): return self.__dict__[attr] + + +def handle_response(func): + """Decorator to handle HTTP response parsing and error squelching.""" + + @wraps(func) + def wrapper(*args, **kwargs): + squelch = kwargs.get("squelch", {}) + try: + response: HTTPResponse = func(*args, **kwargs) + if response: + response_text = response.read().decode("utf-8") + if response_text: + try: + return json.loads(response_text) + except json.JSONDecodeError: + return {"response": response_text} + else: + return {} + else: + return {} + except HTTPError as e: + if e.code in squelch: + return squelch[e.code] + else: + raise + + return wrapper + + +def set_credential_headers( + method: str, + url: str, + access_key: str, + private_key: str, +) -> Dict: + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + headers["x-altus-date"] = formatdate(usegmt=True) + headers["x-altus-auth"] = make_signature_header( + method, + url, + headers, + access_key, + private_key, + ) + + return headers + + +def prepare_body( + data: Dict[str, Any] | None = None, + json_data: Dict[str, Any] | None = None, +) -> str | None: + if json_data is not None: + return json.dumps(json_data) + elif data is not None: + return urlencode(data) + else: + return None + + +class TestCdpClient(CdpClient): + def __init__( + self, + endpoint: str, + access_key: str, + private_key: str, + default_page_size: int = 100, + ): + super().__init__(default_page_size) + self.request = Request(http_agent="TestCdpClient/1.0") + self.endpoint = endpoint.rstrip("/") + self.access_key = access_key + self.private_key = private_key + + @handle_response + def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]: + # Prepare query parameters + if params: + path += "?" + urlencode(params) + + url = f"{self.endpoint}/{path.strip('/')}" + + return Request().get( + url=url, + headers=set_credential_headers( + method="GET", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), + ) + + @handle_response + def post( + self, + path: str, + data: Dict[str, Any] | None = None, + json_data: Dict[str, Any] | None = None, + squelch: Dict[int, Any] = {}, + ) -> Dict[str, Any]: + url = f"{self.endpoint}/{path.strip('/')}" + + return Request().post( + url=url, + headers=set_credential_headers( + method="POST", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), + data=prepare_body(data, json_data), + ) + + def put( + self, + path: str, + data: Dict[str, Any] | None = None, + json_data: Dict[str, Any] | None = None, + squelch: Dict[int, Any] = {}, + ) -> Dict[str, Any]: + url = f"{self.endpoint}/{path.strip('/')}" + + return Request().put( + url=url, + headers=set_credential_headers( + method="PUT", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), + data=prepare_body(data, json_data), + ) + + def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + url = f"{self.endpoint}/{path.strip('/')}" + + return Request().delete( + url=url, + headers=set_credential_headers( + method="DELETE", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 059f8b1f..624ed810 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -23,12 +23,18 @@ import sys import pytest +from pytest import MonkeyPatch +from pytest_mock import MockerFixture +from typing import Callable +from unittest.mock import MagicMock, Mock + from ansible.module_utils import basic from ansible.module_utils.common.text.converters import to_bytes from ansible_collections.cloudera.cloud.tests.unit import ( AnsibleFailJson, AnsibleExitJson, + TestCdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( @@ -77,7 +83,7 @@ def pytest_collection_modifyitems(items): @pytest.fixture -def module_args(): +def module_args() -> Callable[[dict], None]: """Prepare module arguments""" def prep_args(args=dict()): @@ -88,7 +94,7 @@ def prep_args(args=dict()): @pytest.fixture -def module_creds(): +def module_creds() -> dict[str, str]: """Prepare module credentials""" return { @@ -100,7 +106,7 @@ def module_creds(): @pytest.fixture(autouse=True) -def patch_module(monkeypatch): +def patch_module(monkeypatch: MonkeyPatch): """Patch AnsibleModule to raise exceptions on success and failure""" def exit_json(*args, **kwargs): @@ -117,7 +123,7 @@ def fail_json(*args, **kwargs): @pytest.fixture -def mock_ansible_module(mocker): +def mock_ansible_module(mocker: MockerFixture) -> Mock: """Fixture for mock AnsibleModule.""" module = mocker.Mock() module.params = {} @@ -131,7 +137,7 @@ def mock_ansible_module(mocker): @pytest.fixture() -def mock_load_cdp_config(mocker): +def mock_load_cdp_config(mocker: MockerFixture) -> MagicMock: """Mock the load_cdp_config function.""" return mocker.patch( "ansible_collections.cloudera.cloud.plugins.module_utils.common.load_cdp_config", @@ -140,9 +146,9 @@ def mock_load_cdp_config(mocker): @pytest.fixture() -def unset_cdp_env_vars(monkeypatch): +def unset_cdp_env_vars(monkeypatch: MonkeyPatch): """Fixture to unset any prior CDP-related environment variables.""" - monkeypatch.delenv("CDP_ACCESS_KEY", raising=False) + monkeypatch.delenv("CDP_ACCESS_KEY_ID", raising=False) monkeypatch.delenv("CDP_PRIVATE_KEY", raising=False) monkeypatch.delenv("CDP_CREDENTIALS_PATH", raising=False) monkeypatch.delenv("CDP_PROFILE", raising=False) @@ -150,7 +156,10 @@ def unset_cdp_env_vars(monkeypatch): @pytest.fixture() -def api_client(module_creds, mock_ansible_module): +def ansible_cdp_client( + module_creds: dict[str, str], + mock_ansible_module: Mock, +) -> AnsibleCdpClient: """Fixture for creating an Ansible API client instance.""" return AnsibleCdpClient( @@ -159,3 +168,19 @@ def api_client(module_creds, mock_ansible_module): access_key=module_creds["access_key"], private_key=module_creds["private_key"], ) + + +@pytest.fixture(scope="session") +def test_cdp_client() -> TestCdpClient: + if "CDP_ACCESS_KEY_ID" not in os.environ or "CDP_PRIVATE_KEY" not in os.environ: + pytest.skip( + "CDP API credentials not set in env vars. Skipping integration tests.", + ) + + return TestCdpClient( + endpoint=os.getenv("CDP_API_ENDPOINT"), # pyright: ignore[reportArgumentType] + access_key=os.getenv( + "CDP_ACCESS_KEY_ID", + ), # pyright: ignore[reportArgumentType] + private_key=os.getenv("CDP_PRIVATE_KEY"), # pyright: ignore[reportArgumentType] + ) diff --git a/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py b/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py index a15c83f6..8e0dfdcb 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py @@ -25,7 +25,6 @@ from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( AnsibleCdpClient, - CdpError, ) BASE_URL = "https://cloudera.internal/api" diff --git a/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py b/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py index 85615e05..c65be821 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py @@ -18,8 +18,6 @@ __metaclass__ = type -import pytest - from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( create_canonical_request_string, ) diff --git a/tests/unit/plugins/module_utils/cdp_client/test_pagination.py b/tests/unit/plugins/module_utils/cdp_client/test_pagination.py index 050fbd69..d5115d0c 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_pagination.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_pagination.py @@ -19,7 +19,7 @@ __metaclass__ = type from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) @@ -37,8 +37,8 @@ def test_paginated_decorator_single_page(mocker): mock_func.return_value = test_data # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -75,8 +75,8 @@ def test_paginated_decorator_multiple_pages(mocker): mock_func.side_effect = [page1_data, page2_data] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -105,8 +105,8 @@ def test_paginated_decorator_with_custom_page_size_single(mocker): mock_func.return_value = test_data # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated(default_page_size=10) + class TestClient(CdpClient): + @CdpClient.paginated(default_page_size=10) def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -145,8 +145,8 @@ def test_paginated_decorator_with_custom_page_size_multiple(mocker): mock_func.side_effect = [test_data1, test_data2] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated(default_page_size=2) + class TestClient(CdpClient): + @CdpClient.paginated(default_page_size=2) def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -177,8 +177,8 @@ def test_paginated_decorator_non_dict_response(mocker): mock_func.return_value = "Not a dict response" # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -208,8 +208,8 @@ def test_paginated_decorator_empty_list_keys(mock_ansible_module, mocker): mock_func.side_effect = [test_data1, test_data2] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -247,8 +247,8 @@ def test_paginated_decorator_with_explicit_page_size_single(mocker): mock_func.return_value = test_data # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -287,8 +287,8 @@ def test_paginated_decorator_with_explicit_page_size_multiple(mocker): mock_func.side_effect = [test_data1, test_data2] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) diff --git a/tests/unit/plugins/module_utils/cdp_client/test_service_module.py b/tests/unit/plugins/module_utils/cdp_client/test_service_module.py index c54b5452..943820bd 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_service_module.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_service_module.py @@ -25,7 +25,7 @@ from ansible_collections.cloudera.cloud.tests.unit import AnsibleFailJson from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.common import ( ParametersMixin, @@ -239,7 +239,7 @@ def test_services_module_initialization_basic( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_explicit( self, @@ -261,7 +261,7 @@ def test_services_module_initialization_endpoint_explicit( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_default( self, @@ -283,7 +283,7 @@ def test_services_module_initialization_endpoint_region_default( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_us_west_1( self, @@ -305,7 +305,7 @@ def test_services_module_initialization_endpoint_region_us_west_1( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_eu_1( self, @@ -327,7 +327,7 @@ def test_services_module_initialization_endpoint_region_eu_1( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_ap_1( self, @@ -349,7 +349,7 @@ def test_services_module_initialization_endpoint_region_ap_1( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_env( self, @@ -372,7 +372,7 @@ def test_services_module_initialization_endpoint_region_env( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_credentials( self, @@ -395,7 +395,7 @@ def test_services_module_initialization_credentials( assert module.access_key == "explicit-access-key" assert module.private_key == "explicit-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_credentials_env( self, @@ -419,7 +419,7 @@ def test_services_module_initialization_credentials_env( assert module.access_key == "env-access-key" assert module.private_key == "env-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_profile_env( self, @@ -448,7 +448,7 @@ def test_services_module_initialization_profile_env( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_cred_path_env( self, @@ -477,7 +477,7 @@ def test_services_module_initialization_cred_path_env( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_invalid_endpoint_region( self, diff --git a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py index 3dbe8a3b..22f5f0d4 100644 --- a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py +++ b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py @@ -20,10 +20,8 @@ import pytest -from ansible_collections.cloudera.cloud.tests.unit import AnsibleExitJson - from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_consumption import ( CdpConsumptionClient, @@ -52,9 +50,9 @@ def test_list_compute_usage_records(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpConsumptionClient instance client = CdpConsumptionClient(api_client=api_client) @@ -69,10 +67,9 @@ def test_list_compute_usage_records(self, mocker): assert response["records"][0]["id"] == "record1" # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/consumption/listComputeUsageRecords", - None, - { + json_data={ "fromTimestamp": FROM_TIMESTAMP, "toTimestamp": TO_TIMESTAMP, "pageSize": 100, @@ -98,9 +95,9 @@ def test_list_compute_usage_records_pagination(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.side_effect = [mock_response1, mock_response2] + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.side_effect = [mock_response1, mock_response2] # Create the CdpConsumptionClient instance client = CdpConsumptionClient(api_client=api_client) @@ -116,12 +113,11 @@ def test_list_compute_usage_records_pagination(self, mocker): assert response["records"][2]["id"] == "record3" # Verify that the post method was called with correct parameters - api_client._post.assert_has_calls( + api_client.post.assert_has_calls( [ mocker.call( "/api/v1/consumption/listComputeUsageRecords", - None, - { + json_data={ "fromTimestamp": FROM_TIMESTAMP, "toTimestamp": TO_TIMESTAMP, "pageSize": 100, @@ -129,8 +125,7 @@ def test_list_compute_usage_records_pagination(self, mocker): ), mocker.call( "/api/v1/consumption/listComputeUsageRecords", - None, - { + json_data={ "fromTimestamp": FROM_TIMESTAMP, "toTimestamp": TO_TIMESTAMP, "pageSize": 100, @@ -145,11 +140,12 @@ def test_list_compute_usage_records_pagination(self, mocker): class TestCdpConsumptionClientIntegration: """Integration tests for CdpConsumptionClient.""" - def test_list_compute_usage_records(self, api_client): + @pytest.mark.slow + def test_list_compute_usage_records(self, ansible_cdp_client): """Test listing compute usage records.""" # Create the CdpConsumptionClient instance - client = CdpConsumptionClient(api_client=api_client) + client = CdpConsumptionClient(api_client=ansible_cdp_client) response = client.list_compute_usage_records( from_timestamp=FROM_TIMESTAMP, diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py new file mode 100644 index 00000000..2a504a80 --- /dev/null +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + CdpClient, +) +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( + CdpIamClient, +) + + +BASE_URL = "https://cloudera.internal/api" +ACCESS_KEY = "test-access-key" +PRIVATE_KEY = "test-private-key" + +SAMPLE_USERS = [ + "crn:cdp:iam:us-west-1:altus:user:alice@example.com", + "crn:cdp:iam:us-west-1:altus:user:bob@example.com", +] +SAMPLE_MACHINE_USERS = [ + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-2", +] +SAMPLE_ROLES = [ + "crn:cdp:iam:us-west-1:altus:role:PowerUser", + "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", + "crn:cdp:iam:us-west-1:altus:role:DFCatalogAdmin", +] +SAMPLE_RESOURCE_ROLES = [ + { + "resource": "crn:cdp:environments:us-west-1:altus:environment:dev-env", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", + }, + { + "resource": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", + }, +] + + +class TestCdpIamClient: + """Unit tests for CdpIamClient group management methods.""" + + def test_list_groups_no_filter(self, mocker): + """Test listing all IAM groups without filtering.""" + + # Mock response data + mock_response = { + "groups": [ + { + "groupName": "data-engineers", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + }, + { + "groupName": "data-scientists", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-scientists", + "creationDate": "2024-01-20T14:45:00.000Z", + "syncMembershipOnUserLogin": False, + }, + ], + } + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + response = client.list_groups() + + # Validate the response + assert "groups" in response + assert len(response["groups"]) == 2 + assert response["groups"][0]["groupName"] == "data-engineers" + assert response["groups"][1]["syncMembershipOnUserLogin"] == False + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/listGroups", + json_data={ + "pageSize": 100, + }, + squelch={404: {}}, + ) + + def test_list_groups_with_filter(self, mocker): + """Test listing IAM groups filtered by name.""" + + # Mock response data + mock_response = { + "groups": [ + { + "groupName": "data-engineers", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + }, + ], + } + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.list_groups(group_names=["data-engineers"]) + + assert "groups" in response + assert len(response["groups"]) == 1 + assert response["groups"][0]["groupName"] == "data-engineers" + + # Verify that the method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/listGroups", + json_data={ + "pageSize": 100, + "groupNames": ["data-engineers"], + }, + squelch={404: {}}, + ) + + def test_create_group(self, mocker): + """Test creating a new IAM group.""" + + # Mock response data + mock_response = { + "group": { + "groupName": "new-team", + "crn": "crn:cdp:iam:us-west-1:altus:group:new-team", + "creationDate": "2024-02-01T09:00:00.000Z", + "syncMembershipOnUserLogin": True, + }, + } + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.create_group( + group_name="new-team", + sync_membership_on_user_login=True, + ) + + assert "group" in response + assert response["group"]["groupName"] == "new-team" + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/createGroup", + json_data={ + "groupName": "new-team", + "syncMembershipOnUserLogin": True, + }, + ) + + def test_delete_group(self, mocker): + """Test deleting an IAM group.""" + + # Mock response data (delete operations typically return empty) + mock_response = {} + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.delete_group(group_name="old-team") + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/deleteGroup", + json_data={ + "groupName": "old-team", + }, + squelch={404: {}}, + ) + + def test_update_group(self, mocker): + """Test updating an IAM group.""" + + # Mock response data + mock_response = {} + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.update_group( + group_name="existing-team", + sync_membership_on_user_login=False, + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/updateGroup", + json_data={ + "groupName": "existing-team", + "syncMembershipOnUserLogin": False, + }, + ) + + def test_list_group_members(self, mocker): + """Test listing group members.""" + + # Mock response data + mock_response = { + "memberCrns": SAMPLE_USERS + [SAMPLE_MACHINE_USERS[0]], + } + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.list_group_members(group_name="data-engineers") + + assert "memberCrns" in response + assert len(response["memberCrns"]) == 3 + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/listGroupMembers", + json_data={ + "pageSize": 100, + "groupName": "data-engineers", + }, + ) + + def test_add_user_to_group(self, mocker): + """Test adding a user to a group.""" + + # Mock response data + mock_response = {} + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.add_user_to_group( + user_id=SAMPLE_USERS[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/addUserToGroup", + json_data={ + "userId": SAMPLE_USERS[0], + "groupName": "data-engineers", + }, + ) + + def test_remove_user_from_group(self, mocker): + """Test removing a user from a group.""" + + # Mock response data + mock_response = {} + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.remove_user_from_group( + user_id=SAMPLE_USERS[1], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/removeUserFromGroup", + json_data={ + "userId": SAMPLE_USERS[1], + "groupName": "data-engineers", + }, + ) + + def test_add_machine_user_to_group(self, mocker): + """Test adding a machine user to a group.""" + + # Mock response data + mock_response = {} + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.add_machine_user_to_group( + machine_user_name=SAMPLE_MACHINE_USERS[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/addMachineUserToGroup", + json_data={ + "machineUserName": SAMPLE_MACHINE_USERS[0], + "groupName": "data-engineers", + }, + ) + + def test_remove_machine_user_from_group(self, mocker): + """Test removing a machine user from a group.""" + + # Mock response data + mock_response = {} + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.remove_machine_user_from_group( + machine_user_name=SAMPLE_MACHINE_USERS[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/removeMachineUserFromGroup", + json_data={ + "machineUserName": SAMPLE_MACHINE_USERS[0], + "groupName": "data-engineers", + }, + ) + + def test_assign_group_role(self, mocker): + """Test assigning a role to a group.""" + + # Mock response data + mock_response = {} + + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.assign_group_role( + group_name="data-engineers", + role=SAMPLE_ROLES[0], + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + api_client.post.assert_called_once_with( + "/api/v1/iam/assignGroupRole", + json_data={ + "groupName": "data-engineers", + "role": SAMPLE_ROLES[0], + }, + ) diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py new file mode 100644 index 00000000..2f1a1ad2 --- /dev/null +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( + CdpIamClient, +) + + +@pytest.mark.skip(reason="Need to update to use factory fixtures") +@pytest.mark.integration_api +class TestIamGroupIntegration: + """Integration tests for CdpIamClient group management.""" + + def test_create_update_delete_group_lifecycle(self, ansible_cdp_client): + """Integration test for complete group lifecycle: create, update, delete.""" + + client = CdpIamClient(api_client=ansible_cdp_client) + + test_group_name = "test-integration-group" + + try: + # 1. Create a new group + create_response = client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + assert "group" in create_response + assert create_response["group"]["groupName"] == test_group_name + assert create_response["group"]["syncMembershipOnUserLogin"] == True + + # 2. Verify group was created by listing it + list_response = client.list_groups(group_names=[test_group_name]) + assert "groups" in list_response + assert len(list_response["groups"]) == 1 + assert list_response["groups"][0]["groupName"] == test_group_name + + # 3. Update the group (change sync setting) + update_response = client.update_group( + group_name=test_group_name, + sync_membership_on_user_login=False, + ) + assert isinstance(update_response, dict) + + # 4. Verify the update + updated_list = client.list_groups(group_names=[test_group_name]) + assert updated_list["groups"][0]["syncMembershipOnUserLogin"] == False + + # 5. Delete the group + delete_response = client.delete_group(group_name=test_group_name) + assert isinstance(delete_response, dict) + + except Exception as e: + # Cleanup: try to delete the group if test fails + try: + client.delete_group(group_name=test_group_name) + except: + pass + raise e + + def test_group_membership_management(self, ansible_cdp_client): + """Integration test for adding and removing users from a group.""" + + client = CdpIamClient(api_client=ansible_cdp_client) + + test_group_name = "test-membership-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + # 2. Get current user to add to group + current_user = client.get_user() + user_crn = current_user.get("crn") + + if user_crn: + # 3. Add user to group + add_response = client.add_user_to_group( + group_name=test_group_name, + user_id=user_crn, + ) + assert isinstance(add_response, dict) + + # 4. Verify user was added + members_response = client.list_group_members(group_name=test_group_name) + assert "memberCrns" in members_response + assert user_crn in members_response["memberCrns"] + + # 5. Remove user from group + remove_response = client.remove_user_from_group( + group_name=test_group_name, + user_id=user_crn, + ) + assert isinstance(remove_response, dict) + + # 6. Verify user was removed + members_after = client.list_group_members(group_name=test_group_name) + assert user_crn not in members_after.get("memberCrns", []) + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass + + def test_group_role_assignment(self, ansible_cdp_client): + """Integration test for assigning and unassigning roles to/from a group.""" + + client = CdpIamClient(api_client=ansible_cdp_client) + + test_group_name = "test-role-assignment-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + # 2. Get available roles + roles_response = client.list_roles() + if roles_response.get("roles") and len(roles_response["roles"]) > 0: + # Use the first available role for testing + test_role_crn = roles_response["roles"][0]["crn"] + + # 3. Assign role to group + assign_response = client.assign_group_role( + group_name=test_group_name, + role=test_role_crn, + ) + assert isinstance(assign_response, dict) + + # 4. Verify role was assigned + assigned_roles = client.list_group_assigned_roles( + group_name=test_group_name, + ) + assert "roleCrns" in assigned_roles + assert test_role_crn in assigned_roles["roleCrns"] + + # 5. Unassign role from group + unassign_response = client.unassign_group_role( + group_name=test_group_name, + role=test_role_crn, + ) + assert isinstance(unassign_response, dict) + + # 6. Verify role was unassigned + roles_after = client.list_group_assigned_roles( + group_name=test_group_name, + ) + assert test_role_crn not in roles_after.get("roleCrns", []) + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass + + def test_machine_user_group_membership(self, ansible_cdp_client): + """Integration test for adding and removing machine users from a group.""" + + client = CdpIamClient(api_client=ansible_cdp_client) + + test_group_name = "test-machine-user-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + # 2. Get list of machine users + machine_users_response = client.list_machine_users() + + if ( + machine_users_response.get("machineUsers") + and len(machine_users_response["machineUsers"]) > 0 + ): + # Use first available machine user for testing + machine_user_name = machine_users_response["machineUsers"][0][ + "machineUserName" + ] + + # 3. Add machine user to group + add_response = client.add_machine_user_to_group( + group_name=test_group_name, + machine_user_name=machine_user_name, + ) + assert isinstance(add_response, dict) + + # 4. Verify machine user was added + members_response = client.list_group_members(group_name=test_group_name) + assert "memberCrns" in members_response + + # 5. Remove machine user from group + remove_response = client.remove_machine_user_from_group( + group_name=test_group_name, + machine_user_name=machine_user_name, + ) + assert isinstance(remove_response, dict) + + # 6. Verify machine user was removed + members_after = client.list_group_members(group_name=test_group_name) + # Machine user should no longer be in the group + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass diff --git a/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py b/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py index 3a599699..483289af 100644 --- a/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py +++ b/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py @@ -65,7 +65,7 @@ def test_compute_usage_info_integration(module_args): module_args( { "endpoint": os.getenv("CDP_API_ENDPOINT", BASE_URL), - "access_key": os.getenv("CDP_ACCESS_KEY", ACCESS_KEY), + "access_key": os.getenv("CDP_ACCESS_KEY_ID", ACCESS_KEY), "private_key": os.getenv("CDP_PRIVATE_KEY", PRIVATE_KEY), "from_timestamp": "2024-01-31T00:00:00Z", "to_timestamp": "2024-01-31T23:59:59Z", diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py new file mode 100644 index 00000000..dfb6f7b4 --- /dev/null +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import os +import pytest + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + +from ansible_collections.cloudera.cloud.plugins.modules import iam_group + + +BASE_URL = "https://cloudera.internal/api" +ACCESS_KEY = "test-access-key" +PRIVATE_KEY = "test-private-key" +FILE_ACCESS_KEY = "file-access-key" +FILE_PRIVATE_KEY = "file-private-key" +FILE_REGION = "default" + +GROUP_NAME = "test-group" + + +def test_iam_group_default(module_args): + """Test iam_group module with missing parameters.""" + + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + }, + ) + + # Expect the module to fail due to missing required parameter + with pytest.raises(AnsibleFailJson, match="name"): + iam_group.main() + + +def test_iam_group_absent(module_args, mocker): + """Test iam_group module with missing parameters.""" + + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "absent", + }, + ) + + # Patch load_cdp_config to avoid reading real config files + config = mocker.patch( + "ansible_collections.cloudera.cloud.plugins.module_utils.common.load_cdp_config", + ) + config.return_value = (FILE_ACCESS_KEY, FILE_PRIVATE_KEY, FILE_REGION) + + # Patch CdpIamClient to avoid real API calls + client = mocker.patch( + "ansible_collections.cloudera.cloud.plugins.modules.iam_group.CdpIamClient", + autospec=True, + ).return_value + client.get_group_details.return_value = GROUP_NAME + + # Test module execution + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group == {} + + # Verify CdpIamClient was called correctly + client.get_group_details.assert_called_once_with(group_name=GROUP_NAME) + client.delete_group.assert_called_once_with(group_name=GROUP_NAME) diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py new file mode 100644 index 00000000..9948a52d --- /dev/null +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# 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. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import os +import pytest +import uuid + +from typing import Callable, Generator + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import CdpIamClient +from ansible_collections.cloudera.cloud.plugins.modules import iam_group + + +BASE_URL = os.getenv("CDP_API_ENDPOINT", "not set") +ACCESS_KEY = os.getenv("CDP_ACCESS_KEY_ID", "not set") +PRIVATE_KEY = os.getenv("CDP_PRIVATE_KEY", "not set") + +# Generate unique group name for each test run to avoid conflicts +GROUP_NAME = f"test-group-int-{uuid.uuid4().hex[:8]}" + +# Mark all tests in this module as integration tests requiring API credentials +pytestmark = pytest.mark.integration_api + + +@pytest.fixture +def iam_client(test_cdp_client) -> CdpIamClient: + """Fixture to provide an IAM client for tests.""" + return CdpIamClient(api_client=test_cdp_client) + + +@pytest.fixture +def iam_group_delete(iam_client) -> Generator[Callable[[str], None], None, None]: + """Fixture to clean up IAM groups created during tests.""" + + group_names = [] + + def _iam_group_module(name: str): + group_names.append(name) + return + + yield _iam_group_module + + for name in group_names: + try: + iam_client.delete_group(group_name=name) + except Exception as e: + pytest.fail(f"Failed to clean up IAM group: {name}. {e}") + + +@pytest.fixture +def iam_group_create(iam_client, iam_group_delete) -> Callable[[str], None]: + """Fixture to clean up IAM groups created during tests.""" + + def _iam_group_module(name: str, sync: bool = False): + iam_group_delete(name) + iam_client.create_group(group_name=name, sync_membership_on_user_login=sync) + return + + return _iam_group_module + + +@pytest.mark.skip("Utility test, not part of main suite") +def test_iam_user(test_cdp_client, iam_client): + """Test that the IAM client can successfully make an API call.""" + + rest_result = test_cdp_client.post("/iam/getUser", data={}) + assert "user" in rest_result + + iam_result = iam_client.get_user() + assert iam_result + + +def test_iam_group_create(module_args, iam_group_delete): + """Test creating a new IAM group with real API calls.""" + + # Ensure cleanup after the test + iam_group_delete(GROUP_NAME) + + # Execute function + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "present", + "sync": True, + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert result.value.group["syncMembershipOnUserLogin"] is True + assert "crn" in result.value.group + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is False + assert result.value.group["groupName"] == GROUP_NAME + assert result.value.group["syncMembershipOnUserLogin"] is True + assert "crn" in result.value.group + + +def test_iam_group_delete(module_args, iam_group_create): + """Test deleting an IAM group with real API calls.""" + + # Create the group to be deleted + iam_group_create(name=GROUP_NAME, sync=True) + + # Execute function + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "absent", + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is False + + +def test_iam_group_update(module_args, iam_group_create): + """Test updating an IAM group with real API calls.""" + + # Create the group to be updated + iam_group_create(name=GROUP_NAME, sync=False) + + # Execute function + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "present", + "sync": True, # Update sync setting to True + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert result.value.group["syncMembershipOnUserLogin"] is True + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is False + assert result.value.group["syncMembershipOnUserLogin"] is True + + +def test_iam_group_roles_update(module_args, iam_group_delete): + """Test updating IAM group roles with real API calls.""" + + # Ensure cleanup after the test + iam_group_delete(GROUP_NAME) + + # Create group with initial roles + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "present", + "roles": [ + "crn:altus:iam:us-west-1:altus:role:BillingAdmin", + "crn:altus:iam:us-west-1:altus:role:ClassicClustersCreator", + ], + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert len(result.value.group["roles"]) == 2 + assert ( + "crn:altus:iam:us-west-1:altus:role:BillingAdmin" in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:ClassicClustersCreator" + in result.value.group["roles"] + ) + + # Update roles - add three new roles + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "present", + "roles": [ + "crn:altus:iam:us-west-1:altus:role:DFCatalogAdmin", + "crn:altus:iam:us-west-1:altus:role:DFCatalogPublisher", + "crn:altus:iam:us-west-1:altus:role:DFCatalogViewer", + ], + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert len(result.value.group["roles"]) == 5 + # Verify all roles are present + assert ( + "crn:altus:iam:us-west-1:altus:role:BillingAdmin" in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:ClassicClustersCreator" + in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:DFCatalogAdmin" + in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:DFCatalogPublisher" + in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:DFCatalogViewer" + in result.value.group["roles"] + ) + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is False + assert len(result.value.group["roles"]) == 5 diff --git a/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py b/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py index 5baec40f..6f84cdcc 100644 --- a/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py +++ b/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py @@ -26,7 +26,7 @@ ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( CdpIamClient, @@ -60,9 +60,9 @@ def test_list_groups_no_filter(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -74,10 +74,10 @@ def test_list_groups_no_filter(self, mocker): assert response["groups"][0]["groupName"] == "group1" # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/listGroups", - None, - {"pageSize": 100}, + json_data={"pageSize": 100}, + squelch={404: {}}, ) def test_list_groups_with_filter(self, mocker): @@ -94,9 +94,9 @@ def test_list_groups_with_filter(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -108,10 +108,10 @@ def test_list_groups_with_filter(self, mocker): assert response["groups"][0]["groupName"] == "specific-group" # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/listGroups", - None, - {"groupNames": ["specific-group"], "pageSize": 100}, + json_data={"groupNames": ["specific-group"], "pageSize": 100}, + squelch={404: {}}, ) @@ -119,11 +119,11 @@ def test_list_groups_with_filter(self, mocker): class TestCdpIamClientIntegration: """Integration tests for CdpIamClient.""" - def test_list_groups(self, api_client): + def test_list_groups(self, ansible_cdp_client): """Integration test for listing IAM groups.""" # Create the CdpIamClient instance - client = CdpIamClient(api_client=api_client) + client = CdpIamClient(api_client=ansible_cdp_client) response = client.list_groups()