Repository: getmoto/moto
Pull request #7432 (2024-03-06 20:37:19)

EMR: Allow CF deletion of Cluster, Create/Delete of SecurityConfig
See #7381 


Patch:
diff --git a/moto/emr/models.py b/moto/emr/models.py
index 58f65613c..c73923919 100644
--- a/moto/emr/models.py
+++ b/moto/emr/models.py
@@ -449,13 +449,64 @@ class FakeCluster(CloudFormationModel):
         cluster.add_tags(tags)
         return cluster
 
+    @classmethod
+    def delete_from_cloudformation_json(  # type: ignore[misc]
+        cls,
+        resource_name: str,
+        cloudformation_json: Dict[str, Any],
+        account_id: str,
+        region_name: str,
+    ) -> None:
+        emr_backend: ElasticMapReduceBackend = emr_backends[account_id][region_name]
+
+        emr_backend.terminate_job_flows([resource_name])
 
-class FakeSecurityConfiguration(BaseModel):
+
+class FakeSecurityConfiguration(CloudFormationModel):
     def __init__(self, name: str, security_configuration: str):
         self.name = name
         self.security_configuration = security_configuration
         self.creation_date_time = datetime.now(timezone.utc)
 
+    @property
+    def physical_resource_id(self) -> str:
+        return self.name
+
+    @staticmethod
+    def cloudformation_type() -> str:
+        return "AWS::EMR::SecurityConfiguration"
+
+    @classmethod
+    def create_from_cloudformation_json(  # type: ignore[misc]
+        cls,
+        resource_name: str,
+        cloudformation_json: Any,
+        account_id: str,
+        region_name: str,
+        **kwargs: Any,
+    ) -> "FakeSecurityConfiguration":
+        emr_backend: ElasticMapReduceBackend = emr_backends[account_id][region_name]
+
+        properties = cloudformation_json["Properties"]
+        return emr_backend.create_security_configuration(
+            name=properties.get("Name") or resource_name,
+            security_configuration=properties.get("SecurityConfiguration", {}),
+        )
+
+    @classmethod
+    def delete_from_cloudformation_json(  # type: ignore[misc]
+        cls,
+        resource_name: str,
+        cloudformation_json: Dict[str, Any],
+        account_id: str,
+        region_name: str,
+    ) -> None:
+        emr_backend: ElasticMapReduceBackend = emr_backends[account_id][region_name]
+
+        properties = cloudformation_json["Properties"]
+        name = properties.get("Name") or resource_name
+        emr_backend.delete_security_configuration(name)
+
 
 class ElasticMapReduceBackend(BaseBackend):
     def __init__(self, region_name: str, account_id: str):
diff --git a/moto/iam/models.py b/moto/iam/models.py
index 6a21e0781..1231544cb 100644
--- a/moto/iam/models.py
+++ b/moto/iam/models.py
@@ -957,7 +957,9 @@ class InstanceProfile(CloudFormationModel):
         account_id: str,
         region_name: str,
     ) -> None:
-        iam_backends[account_id]["global"].delete_instance_profile(resource_name)
+        iam_backends[account_id]["global"].delete_instance_profile(
+            resource_name, ignore_attached_roles=True
+        )
 
     def delete_role(self, role_name: str) -> None:
         self.roles = [role for role in self.roles if role.name != role_name]
@@ -2370,9 +2372,11 @@ class IAMBackend(BaseBackend):
         self.instance_profiles[name] = instance_profile
         return instance_profile
 
-    def delete_instance_profile(self, name: str) -> None:
+    def delete_instance_profile(
+        self, name: str, ignore_attached_roles: bool = False
+    ) -> None:
         instance_profile = self.get_instance_profile(name)
-        if len(instance_profile.roles) > 0:
+        if len(instance_profile.roles) > 0 and not ignore_attached_roles:
             raise IAMConflictException(
                 code="DeleteConflict",
                 message="Cannot delete entity, must remove roles from instance profile first.",


Test patch:
diff --git a/tests/test_emr/test_emr_cloudformation.py b/tests/test_emr/test_emr_cloudformation.py
index 3bebfd22a..84bef4b25 100644
--- a/tests/test_emr/test_emr_cloudformation.py
+++ b/tests/test_emr/test_emr_cloudformation.py
@@ -1,6 +1,8 @@
 import json
 
 import boto3
+import pytest
+from botocore.exceptions import ClientError
 
 from moto import mock_aws
 from tests import EXAMPLE_AMI_ID
@@ -496,6 +498,7 @@ template_with_kerberos_attrs = {
         "securityConfiguration": {
             "Type": "AWS::EMR::SecurityConfiguration",
             "Properties": {
+                "Name": "mysecconfig",
                 "SecurityConfiguration": {
                     "AuthenticationConfiguration": {
                         "KerberosConfiguration": {
@@ -511,7 +514,7 @@ template_with_kerberos_attrs = {
                             },
                         }
                     }
-                }
+                },
             },
         },
         "emrRole": {
@@ -618,3 +621,14 @@ def test_create_cluster_with_kerberos_attrs():
         "KdcAdminPassword": "adminp2ss",
         "CrossRealmTrustPrincipalPassword": "p2ss",
     }
+
+    # Verify everything can be deleted
+    cf.delete_stack(StackName="teststack")
+
+    cl = emr.describe_cluster(ClusterId=cluster_id)["Cluster"]
+    assert cl["Status"]["State"] == "TERMINATED"
+
+    with pytest.raises(ClientError) as exc:
+        emr.describe_security_configuration(Name="mysecconfig")
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidRequestException"
diff --git a/tests/test_iam/__init__.py b/tests/test_iam/__init__.py
index 08a1c1568..466103fb0 100644
--- a/tests/test_iam/__init__.py
+++ b/tests/test_iam/__init__.py
@@ -1,1 +1,28 @@
-# This file is intentionally left blank.
+import os
+from functools import wraps
+
+from moto import mock_aws
+
+
+def iam_aws_verified(func):
+    """
+    Function that is verified to work against AWS.
+    Can be run against AWS at any time by setting:
+      MOTO_TEST_ALLOW_AWS_REQUEST=true
+
+    If this environment variable is not set, the function runs in a `mock_aws` context.
+    """
+
+    @wraps(func)
+    def pagination_wrapper():
+        allow_aws_request = (
+            os.environ.get("MOTO_TEST_ALLOW_AWS_REQUEST", "false").lower() == "true"
+        )
+
+        if allow_aws_request:
+            return func()
+        else:
+            with mock_aws():
+                return func()
+
+    return pagination_wrapper
diff --git a/tests/test_iam/test_iam_cloudformation.py b/tests/test_iam/test_iam_cloudformation.py
index aae856d18..2ac97ff82 100644
--- a/tests/test_iam/test_iam_cloudformation.py
+++ b/tests/test_iam/test_iam_cloudformation.py
@@ -1,4 +1,5 @@
 import json
+from uuid import uuid4
 
 import boto3
 import pytest
@@ -8,6 +9,8 @@ from botocore.exceptions import ClientError
 from moto import mock_aws
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 from tests import EXAMPLE_AMI_ID
+from tests.test_iam import iam_aws_verified
+from tests.test_iam.test_iam import MOCK_STS_EC2_POLICY_DOCUMENT
 
 TEMPLATE_MINIMAL_ROLE = """
 AWSTemplateFormatVersion: 2010-09-09
@@ -1609,3 +1612,46 @@ def test_iam_roles():
         if resource["ResourceType"] == "AWS::IAM::Role"
     ]
     assert {r["PhysicalResourceId"] for r in role_resources} == set(role_names)
+
+
+template_with_instance_profile = """
+Parameters:
+    InputRole:
+        Type: String
+        Default: "test-emr-role"
+
+Resources:
+    emrEc2InstanceProfile:
+        Type: 'AWS::IAM::InstanceProfile'
+        Properties:
+            Path: /
+            Roles:
+              - !Ref InputRole
+"""
+
+
+@iam_aws_verified
+def test_delete_instance_profile_with_existing_role():
+    region = "us-east-1"
+    iam = boto3.client("iam", region_name=region)
+    iam_role_name = f"moto_{str(uuid4())[0:6]}"
+    iam.create_role(
+        RoleName=iam_role_name, AssumeRolePolicyDocument=MOCK_STS_EC2_POLICY_DOCUMENT
+    )
+
+    try:
+        cf = boto3.client("cloudformation", region_name=region)
+        cf.create_stack(
+            StackName="teststack",
+            TemplateBody=template_with_instance_profile,
+            Parameters=[{"ParameterKey": "InputRole", "ParameterValue": iam_role_name}],
+            Capabilities=["CAPABILITY_NAMED_IAM"],
+        )
+
+        # Just verify that we can delete the InstanceProfile
+        cf.delete_stack(StackName="teststack")
+
+        # The role still exists at this point
+        iam.get_role(RoleName=iam_role_name)
+    finally:
+        iam.delete_role(RoleName=iam_role_name)