from typing import Optional, Any, Dict, List, Union, Callable from phi.aws.api_client import AwsApiClient from phi.aws.resource.base import AwsResource from phi.aws.resource.ec2.subnet import Subnet from phi.aws.resource.reference import AwsReference from phi.cli.console import print_info from phi.utils.log import logger def get_my_ip() -> str: """Returns the network ip""" import httpx external_ip = httpx.get("https://checkip.amazonaws.com").text.strip() return f"{external_ip}/32" class InboundRule(AwsResource): """ Reference: - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/authorize_security_group_ingress.html """ name: str = "InboundRule" resource_type: Optional[str] = "InboundRule" service_name: str = "ec2" # What to enable ingress for. # The IPv4 CIDR range. You can either specify a CIDR range or a source security group, not both. # To specify a single IPv4 address, use the /32 prefix length. cidr_ip: Optional[str] = None # The function to get the cidr_ip cidr_ip_function: Optional[Callable[..., str]] = None # The IPv6 CIDR range. You can either specify a CIDR range or a source security group, not both. # To specify a single IPv6 address, use the /128 prefix length. cidr_ipv6: Optional[str] = None # The function to get the cidr_ipv6 cidr_ipv6_function: Optional[Callable[..., str]] = None # The security group id to allow access from. security_group_id: Optional[Union[str, AwsReference]] = None # The security group name to allow access from. # For a security group in a nondefault VPC, use the security group ID. security_group_name: Optional[str] = None # A description for this security group rule description: Optional[str] = None # The port to allow access from. # If provided, sets both from_port and to_port. port: Optional[int] = None # The port range to allow access from. from_port: Optional[int] = None # The port range to allow access from. to_port: Optional[int] = None # The protocol to allow access from. Default is tcp. ip_protocol: Optional[str] = None class OutboundRule(AwsResource): """ Reference: - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/authorize_security_group_ingress.html """ name: str = "OutboundRule" resource_type: Optional[str] = "OutboundRule" service_name: str = "ec2" # What to enable egress for. # The IPv4 CIDR range. You can either specify a CIDR range or a source security group, not both. # To specify a single IPv4 address, use the /32 prefix length. cidr_ip: Optional[str] = None # The function to get the cidr_ip cidr_ip_function: Optional[Callable[..., str]] = None # The IPv6 CIDR range. You can either specify a CIDR range or a source security group, not both. # To specify a single IPv6 address, use the /128 prefix length. cidr_ipv6: Optional[str] = None # The function to get the cidr_ipv6 cidr_ipv6_function: Optional[Callable[..., str]] = None # The security group id to allow access to. security_group_id: Optional[Union[str, AwsReference]] = None # The security group name to allow access to. # For a security group in a nondefault VPC, use the security group ID. security_group_name: Optional[str] = None # A description for this security group rule description: Optional[str] = None # The port to allow access from. # If provided, sets both from_port and to_port. port: Optional[int] = None # The port range to allow access from. from_port: Optional[int] = None # The port range to allow access from. to_port: Optional[int] = None # The protocol to allow access from. Default is tcp. ip_protocol: Optional[str] = None class SecurityGroup(AwsResource): """ Reference: - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/securitygroup/index.html - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/create_security_group.html """ resource_type: Optional[str] = "SecurityGroup" resource_type_list: List[str] = ["sg"] service_name: str = "ec2" # The name of the security group. name: str # A description for the security group. description: Optional[str] = None # The ID of the VPC for the security group. vpc_id: Optional[str] = None # Derive the vpc_id from the subnets. # When more than one subnet is provided, both must be in the same VPC. subnets: Optional[List[Union[str, Subnet]]] = None # The tags to assign to the security group. tag_specifications: Optional[list] = None # Checks whether you have the required permissions for the action, # without actually making the request, and provides an error response. # If you have the required permissions, the error response is DryRunOperation. # Otherwise, it is UnauthorizedOperation. dry_run: Optional[bool] = None # The inbound rules associated with the security group. inbound_rules: Optional[List[InboundRule]] = None # The IP permissions to authorize ingress for ingress_ip_permissions: Optional[List[Dict[str, Any]]] = None # The outbound rules associated with the security group. outbound_rules: Optional[List[OutboundRule]] = None # The IP permissions to authorize egress for egress_ip_permissions: Optional[List[Dict[str, Any]]] = None # Security Group id group_id: Optional[str] = None def _create(self, aws_client: AwsApiClient) -> bool: """Creates the SecurityGroup Args: aws_client: The AwsApiClient for the current Security group """ print_info(f"Creating {self.get_resource_type()}: {self.get_resource_name()}") # Step 1: Build Security group configuration # create a dict of args which are not null, otherwise aws type validation fails not_null_args: Dict[str, Any] = {} # Build description description = self.description or "Created by phi" if description is not None: not_null_args["Description"] = description # Get vpc_id vpc_id = self.vpc_id if vpc_id is None and self.subnets is not None: from phi.aws.resource.ec2.subnet import get_vpc_id_from_subnet_ids subnet_ids = [] for subnet in self.subnets: if isinstance(subnet, Subnet): subnet_ids.append(subnet.name) elif isinstance(subnet, str): subnet_ids.append(subnet) vpc_id = get_vpc_id_from_subnet_ids(subnet_ids, aws_client) if vpc_id is not None: not_null_args["VpcId"] = vpc_id if self.tag_specifications: not_null_args["TagSpecifications"] = self.tag_specifications if self.dry_run: not_null_args["DryRun"] = self.dry_run # Step 2: Create Security group service_client = self.get_service_client(aws_client) try: create_response = service_client.create_security_group( GroupName=self.name, **not_null_args, ) logger.debug(f"Response: {create_response}") # Validate resource creation if create_response is not None: self.active_resource = create_response return True except Exception as e: logger.error(f"{self.get_resource_type()} could not be created.") logger.error(e) return False def post_create(self, aws_client: AwsApiClient) -> bool: # Wait for SecurityGroup to be created if self.wait_for_create: try: print_info(f"Waiting for {self.get_resource_type()} to be created.") waiter = self.get_service_client(aws_client).get_waiter("security_group_exists") waiter.wait( Filters=[ { "Name": "group-name", "Values": [self.name], }, ], WaiterConfig={ "Delay": self.waiter_delay, "MaxAttempts": self.waiter_max_attempts, }, ) except Exception as e: logger.error("Waiter failed.") logger.error(e) return False # Add inbound rules if self.inbound_rules is not None or self.ingress_ip_permissions: _success = self.add_inbound_rules(aws_client) if not _success: return False # Add outbound rules if self.outbound_rules is not None or self.egress_ip_permissions: _success = self.add_outbound_rules(aws_client) if not _success: return False return True def _read(self, aws_client: AwsApiClient) -> Optional[Any]: """Reads the SecurityGroup Args: aws_client: The AwsApiClient for the current session """ from botocore.exceptions import ClientError logger.debug(f"Reading {self.get_resource_type()}: {self.get_resource_name()}") service_client = self.get_service_client(aws_client) try: describe_response = service_client.describe_security_groups( Filters=[ { "Name": "group-name", "Values": [self.name], }, ], ) logger.debug(f"Response: {describe_response}") resource_list = describe_response.get("SecurityGroups", None) if resource_list is not None and isinstance(resource_list, list): for resource in resource_list: if resource.get("GroupName", None) == self.name: self.active_resource = resource except ClientError as ce: logger.debug(f"ClientError: {ce}") except Exception as e: logger.error(f"Error reading {self.get_resource_type()}.") logger.error(e) return self.active_resource def _delete(self, aws_client: AwsApiClient) -> bool: """Deletes the SecurityGroup Args: aws_client: The AwsApiClient for the current session """ from botocore.exceptions import ClientError print_info(f"Deleting {self.get_resource_type()}: {self.get_resource_name()}") service_client = self.get_service_client(aws_client) self.active_resource = None try: group_id = self.get_security_group_id(aws_client) if group_id is not None: delete_response = service_client.delete_security_group(GroupId=group_id) else: delete_response = service_client.delete_security_group(GroupName=self.name) logger.debug(f"Response: {delete_response}") return True except ClientError as ce: ce_resp = ce.response if ce_resp is not None: if ce_resp.get("Error", {}).get("Code", "") == "DependencyViolation": logger.warning( f"SecurityGroup {self.get_resource_name()} could not be deleted " f"as it is being used by another resource." ) if ce_resp.get("Error", {}).get("Message", "") != "": logger.warning(f"Error: {ce_resp.get('Error', {}).get('Message', '')}") logger.warning("Please try again later or delete resources manually.") except Exception as e: logger.error(f"{self.get_resource_type()} could not be deleted.") logger.error("Please try again or delete resources manually.") logger.error(e) return False def _update(self, aws_client: AwsApiClient) -> bool: """Updates the SecurityGroup""" print_info(f"Updating {self.get_resource_type()}: {self.get_resource_name()}") # Step 1: Update inbound rules if self.inbound_rules is not None or self.ingress_ip_permissions: _success = self.add_inbound_rules(aws_client) if not _success: return False # Step 2: Update outbound rules if self.outbound_rules is not None or self.egress_ip_permissions: _success = self.add_outbound_rules(aws_client) if not _success: return False return True def get_security_group_id(self, aws_client: Optional[AwsApiClient] = None) -> Optional[str]: """Returns the security group id""" if self.group_id is not None: return self.group_id resource = self.read(aws_client) if resource is not None: self.group_id = resource.get("GroupId", None) return self.group_id def add_inbound_rules(self, aws_client: AwsApiClient) -> bool: """Adds the specified inbound (ingress) rules to a security group. Args: aws_client: The AwsApiClient for the current session """ from botocore.exceptions import ClientError # create a dict of args which are not null, otherwise aws type validation fails api_args: Dict[str, Any] = {} group_id = self.get_security_group_id(aws_client) if group_id is None: logger.warning(f"GroupId for {self.get_resource_name()} not found.") return False api_args["GroupId"] = group_id if self.dry_run is not None: api_args["DryRun"] = self.dry_run service_client = self.get_service_client(aws_client) # Add ingress_ip_permissions if self.ingress_ip_permissions is not None: try: response = service_client.authorize_security_group_ingress( IpPermissions=self.ingress_ip_permissions, **api_args ) logger.debug(f"Response: {response}") # Validate the response if response is None or response.get("Return") is False: logger.error(f"Ingress rules could not be added to {self.get_resource_name()}") return False except ClientError as ce: ce_resp = ce.response if ce_resp is not None: if ce_resp.get("Error", {}).get("Code", "") == "InvalidPermission.Duplicate": pass logger.debug(f"ClientError: {ce}") except Exception as e: logger.error(f"Ingress rules could not be added to {self.get_resource_name()}: {e}") return False # Add inbound_rules if self.inbound_rules is not None: for rule in self.inbound_rules: ip_permission: Dict[str, Any] = {"IpProtocol": rule.ip_protocol or "tcp"} if rule.from_port is not None: ip_permission["FromPort"] = rule.from_port if rule.to_port is not None: ip_permission["ToPort"] = rule.to_port if rule.port is not None: ip_permission["FromPort"] = rule.port ip_permission["ToPort"] = rule.port # Get cidr_ip _cidr_ip: Optional[str] = None if rule.cidr_ip is not None: _cidr_ip = rule.cidr_ip elif rule.cidr_ip_function is not None: try: _cidr_ip = rule.cidr_ip_function() except Exception as e: logger.warning(f"Error getting cidr_ip for {self.get_resource_name()}: {e}") if _cidr_ip is not None: ip_permission["IpRanges"] = [ { "CidrIp": _cidr_ip, "Description": rule.description or "", }, ] # Get cidr_ipv6 _cidr_ipv6: Optional[str] = None if rule.cidr_ipv6 is not None: _cidr_ipv6 = rule.cidr_ipv6 elif rule.cidr_ipv6_function is not None: try: _cidr_ipv6 = rule.cidr_ipv6_function() except Exception as e: logger.warning(f"Error getting cidr_ipv6 for {self.get_resource_name()}: {e}") if _cidr_ipv6 is not None: ip_permission["Ipv6Ranges"] = [ { "CidrIpv6": _cidr_ipv6, "Description": rule.description or "", }, ] if _cidr_ip is None and _cidr_ipv6 is None: source_sg_id: Optional[str] = None # If security_group_id is specified, use that # Otherwise, use the current security group id if rule.security_group_id is not None: if isinstance(rule.security_group_id, str): source_sg_id = rule.security_group_id elif isinstance(rule.security_group_id, AwsReference): source_sg_id = rule.security_group_id.get_reference(aws_client=aws_client) else: source_sg_id = group_id # Either security_group_id or security_group_name must be specified # for the rule to be valid if source_sg_id is not None or rule.security_group_name is not None: user_id_group_pair = {} if source_sg_id is not None: user_id_group_pair["GroupId"] = source_sg_id if rule.security_group_name is not None: user_id_group_pair["GroupName"] = rule.security_group_name if rule.description is not None: user_id_group_pair["Description"] = rule.description ip_permission["UserIdGroupPairs"] = [user_id_group_pair] logger.debug(f"Adding Inbound Rule: {ip_permission}") try: response = service_client.authorize_security_group_ingress( IpPermissions=[ip_permission], **api_args ) logger.debug(f"Response: {response}") # Validate the response if response is None or response.get("Return") is False: logger.error(f"Ingress rules could not be added to {self.get_resource_name()}") return False except ClientError as ce: ce_resp = ce.response if ce_resp is not None: if ce_resp.get("Error", {}).get("Code", "") == "InvalidPermission.Duplicate": pass logger.debug(f"ClientError: {ce}") except Exception as e: logger.error(f"Ingress rules could not be added to {self.get_resource_name()}: {e}") return False return True def add_outbound_rules(self, aws_client: AwsApiClient) -> bool: """Adds the specified outbound (egress) rules to a security group. Args: aws_client: The AwsApiClient for the current session """ from botocore.exceptions import ClientError # create a dict of args which are not null, otherwise aws type validation fails api_args: Dict[str, Any] = {} group_id = self.get_security_group_id(aws_client) if group_id is None: logger.warning(f"GroupId for {self.get_resource_name()} not found.") return False api_args["GroupId"] = group_id if self.dry_run is not None: api_args["DryRun"] = self.dry_run service_client = self.get_service_client(aws_client) # Add egress_ip_permissions if self.egress_ip_permissions is not None: try: response = service_client.authorize_security_group_egress( IpPermissions=self.egress_ip_permissions, **api_args ) logger.debug(f"Response: {response}") # Validate the response if response is None or response.get("Return") is False: logger.error(f"Egress rules could not be added to {self.get_resource_name()}") return False except ClientError as ce: ce_resp = ce.response if ce_resp is not None: if ce_resp.get("Error", {}).get("Code", "") == "InvalidPermission.Duplicate": pass logger.debug(f"ClientError: {ce}") except Exception as e: logger.error(f"Egress rules could not be added to {self.get_resource_name()}: {e}") return False # Add outbound_rules if self.outbound_rules is not None: for rule in self.outbound_rules: ip_permission: Dict[str, Any] = {"IpProtocol": rule.ip_protocol or "tcp"} if rule.from_port is not None: ip_permission["FromPort"] = rule.from_port if rule.to_port is not None: ip_permission["ToPort"] = rule.to_port if rule.port is not None: ip_permission["FromPort"] = rule.port ip_permission["ToPort"] = rule.port # Get cidr_ip _cidr_ip: Optional[str] = None if rule.cidr_ip is not None: _cidr_ip = rule.cidr_ip elif rule.cidr_ip_function is not None: try: _cidr_ip = rule.cidr_ip_function() except Exception as e: logger.warning(f"Error getting cidr_ip for {self.get_resource_name()}: {e}") if _cidr_ip is not None: ip_permission["IpRanges"] = [ { "CidrIp": _cidr_ip, "Description": rule.description or "", }, ] # Get cidr_ipv6 _cidr_ipv6: Optional[str] = None if rule.cidr_ipv6 is not None: _cidr_ipv6 = rule.cidr_ipv6 elif rule.cidr_ipv6_function is not None: try: _cidr_ipv6 = rule.cidr_ipv6_function() except Exception as e: logger.warning(f"Error getting cidr_ipv6 for {self.get_resource_name()}: {e}") if _cidr_ipv6 is not None: ip_permission["Ipv6Ranges"] = [ { "CidrIpv6": _cidr_ipv6, "Description": rule.description or "", }, ] if _cidr_ip is None and _cidr_ipv6 is None: destination_sg_id: Optional[str] = None if isinstance(rule.security_group_id, str): destination_sg_id = rule.security_group_id elif isinstance(rule.security_group_id, AwsReference): destination_sg_id = rule.security_group_id.get_reference(aws_client=aws_client) user_id_group_pair = {} if destination_sg_id is not None: user_id_group_pair["GroupId"] = destination_sg_id if rule.security_group_name is not None: user_id_group_pair["GroupName"] = rule.security_group_name if rule.description is not None: user_id_group_pair["Description"] = rule.description ip_permission["UserIdGroupPairs"] = [user_id_group_pair] logger.debug(f"Adding Outbound Rule: {ip_permission}") try: response = service_client.authorize_security_group_egress(IpPermissions=[ip_permission], **api_args) logger.debug(f"Response: {response}") # Validate the response if response is None or response.get("Return") is False: logger.error(f"Egress rules could not be added to {self.get_resource_name()}") return False except ClientError as ce: ce_resp = ce.response if ce_resp is not None: if ce_resp.get("Error", {}).get("Code", "") == "InvalidPermission.Duplicate": pass logger.debug(f"ClientError: {ce}") except Exception as e: logger.error(f"Egress rules could not be added to {self.get_resource_name()}: {e}") return False return True