diff --git a/localstack-core/localstack/services/cloudformation/v2/provider.py b/localstack-core/localstack/services/cloudformation/v2/provider.py index 3066a7fc45105..2ee1a88592d59 100644 --- a/localstack-core/localstack/services/cloudformation/v2/provider.py +++ b/localstack-core/localstack/services/cloudformation/v2/provider.py @@ -7,6 +7,7 @@ from localstack.aws.api import RequestContext, handler from localstack.aws.api.cloudformation import ( + Account, CallAs, Changes, ChangeSetNameOrId, @@ -29,6 +30,7 @@ DeletionMode, DescribeChangeSetOutput, DescribeStackEventsOutput, + DescribeStackInstanceOutput, DescribeStackResourcesOutput, DescribeStackSetOperationOutput, DescribeStacksOutput, @@ -47,10 +49,14 @@ NextToken, Parameter, PhysicalResourceId, + Region, RetainExceptOnCreate, RetainResources, RoleARN, RollbackConfiguration, + StackDriftStatus, + StackInstanceComprehensiveStatus, + StackInstanceDetailedStatus, StackName, StackNameOrId, StackSetName, @@ -65,6 +71,9 @@ UpdateStackOutput, UpdateTerminationProtectionOutput, ) +from localstack.aws.api.cloudformation import ( + StackInstance as ApiStackInstance, +) from localstack.aws.connect import connect_to from localstack.services.cloudformation import api_utils from localstack.services.cloudformation.engine import template_preparer @@ -826,6 +835,49 @@ def describe_stack_set_operation( return DescribeStackSetOperationOutput(StackSetOperation=result) + @handler("DescribeStackInstance") + def describe_stack_instance( + self, + context: RequestContext, + stack_set_name: StackSetName, + stack_instance_account: Account, + stack_instance_region: Region, + call_as: CallAs | None = None, + **kwargs, + ) -> DescribeStackInstanceOutput: + state = get_cloudformation_store(context.account_id, context.region) + stack_set = find_stack_set_v2(state, stack_set_name) + if not stack_set: + # TODO: message parity + raise RuntimeError(f"Could not find stack set '{stack_set_name}'") + + for instance in stack_set.stack_instances: + if instance.account_id != stack_instance_account: + continue + if instance.region_name != stack_instance_region: + continue + + return DescribeStackInstanceOutput( + StackInstance=ApiStackInstance( + StackSetId=stack_set.stack_set_id, + Region=instance.region_name, + Account=instance.account_id, + StackId=instance.stack_id, + DriftStatus=StackDriftStatus.NOT_CHECKED, + LastOperationId=instance.operation_id, + # TODO: fixed + StackInstanceStatus=StackInstanceComprehensiveStatus( + DetailedStatus=StackInstanceDetailedStatus.SUCCEEDED + ), + Status=instance.status, + ) + ) + + # TODO: message parity + raise RuntimeError( + f"Could not find stack instance for account '{stack_instance_account}' and region '{stack_instance_region}'" + ) + @handler("DeleteStackInstances", expand=False) def delete_stack_instances( self, diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py index 9d85f36aba3d2..bd8daf8e08208 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py @@ -32,7 +32,54 @@ def _operation_is_ready(): return waiter -@markers.aws.manual_setup_required +@pytest.fixture(scope="session") +def setup_account_for_stack_sets(aws_client): + template_path = os.path.join( + os.path.dirname(__file__), + "../../../../../templates/AWSCloudFormationStackSetAdministrationRole.yml", + ) + assert os.path.isfile(template_path) + + # replicating deploy_cfn_template since it's a function scoped fixture + stack_name = f"stack-{short_uid()}" + with open(template_path) as infile: + template_body = infile.read() + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template_body, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ) + stack_id = stack["StackId"] + aws_client.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack_id, + WaiterConfig={ + # 5 minutes + "Delay": 3, + "MaxAttempts": 100, + }, + ) + yield + aws_client.cloudformation.delete_stack(StackName=stack_id) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait( + StackName=stack_id, + WaiterConfig={ + # 5 minutes + "Delay": 3, + "MaxAttempts": 100, + }, + ) + + +@markers.aws.validated +@pytest.mark.usefixtures("setup_account_for_stack_sets") +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..LastOperationId", + "$..OrganizationalUnitId", + "$..ParameterOverrides", + "$..StatusReason", + ] +) def test_create_stack_set_with_stack_instances( account_id, region_name, @@ -40,8 +87,8 @@ def test_create_stack_set_with_stack_instances( snapshot, wait_stack_set_operation, ): - """ "Account <...> should have 'AWSCloudFormationStackSetAdministrationRole' role with trust relationship to CloudFormation service.""" - snapshot.add_transformer(snapshot.transform.key_value("StackSetId", "stack-set-id")) + snapshot.add_transformer(snapshot.transform.key_value("StackSetId")) + snapshot.add_transformer(snapshot.transform.key_value("StackId")) stack_set_name = f"StackSet-{short_uid()}" @@ -78,6 +125,33 @@ def test_create_stack_set_with_stack_instances( wait_stack_set_operation(stack_set_name, create_instances_result["OperationId"]) + # check the resources actually exist + stack_instance = aws_client.cloudformation.describe_stack_instance( + StackSetName=stack_set_name, + StackInstanceAccount=account_id, + StackInstanceRegion=region_name, + )["StackInstance"] + snapshot.match("describe-stack-instance", stack_instance) + + stack_instance_stack_id = stack_instance["StackId"] + aws_client.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack_instance_stack_id, + WaiterConfig={ + "Delay": 3, + "MaxAttempts": 100, + }, + ) + outputs = aws_client.cloudformation.describe_stacks(StackName=stack_instance_stack_id)[ + "Stacks" + ][0]["Outputs"] + bucket_names = [ + output["OutputValue"] + for output in outputs + if output["OutputKey"] in {"BucketNameAllParameters", "BucketNameOnlyRequired"} + ] + for bucket_name in bucket_names: + aws_client.s3.head_bucket(Bucket=bucket_name) + delete_instances_result = aws_client.cloudformation.delete_stack_instances( StackSetName=stack_set_name, Accounts=[account_id], diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.snapshot.json index 9ce96422b848a..fc62e2d8ac9b0 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.snapshot.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.snapshot.json @@ -1,6 +1,6 @@ { "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": { - "recorded-date": "24-05-2023, 15:32:47", + "recorded-date": "28-07-2025, 21:51:09", "recorded-content": { "create_stack_set": { "StackSetId": "", @@ -15,6 +15,21 @@ "HTTPHeaders": {}, "HTTPStatusCode": 200 } + }, + "describe-stack-instance": { + "Account": "111111111111", + "DriftStatus": "NOT_CHECKED", + "LastOperationId": "", + "OrganizationalUnitId": "", + "ParameterOverrides": [], + "Region": "", + "StackId": "", + "StackInstanceStatus": { + "DetailedStatus": "SUCCEEDED" + }, + "StackSetId": "", + "Status": "CURRENT", + "StatusReason": "No updates are to be performed." } } }, diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.validation.json index 80af31738711a..7936c01c4e83e 100644 --- a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.validation.json +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.validation.json @@ -1,6 +1,12 @@ { "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": { - "last_validated_date": "2023-05-24T13:32:47+00:00" + "last_validated_date": "2025-07-28T21:51:31+00:00", + "durations_in_seconds": { + "setup": 44.54, + "call": 48.24, + "teardown": 21.74, + "total": 114.52 + } }, "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py::test_delete_nonexistent_stack_set": { "last_validated_date": "2025-07-25T14:57:22+00:00", diff --git a/tests/aws/templates/AWSCloudFormationStackSetAdministrationRole.yml b/tests/aws/templates/AWSCloudFormationStackSetAdministrationRole.yml new file mode 100644 index 0000000000000..f9e243d7b39da --- /dev/null +++ b/tests/aws/templates/AWSCloudFormationStackSetAdministrationRole.yml @@ -0,0 +1,55 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: Configure the AWSCloudFormationStackSetAdministrationRole to enable use of AWS CloudFormation StackSets. + +Parameters: + AdministrationRoleName: + Type: String + Default: AWSCloudFormationStackSetAdministrationRole + Description: "The name of the administration role. Defaults to 'AWSCloudFormationStackSetAdministrationRole'." + ExecutionRoleName: + Type: String + Default: AWSCloudFormationStackSetExecutionRole + Description: "The name of the execution role that can assume this role. Defaults to 'AWSCloudFormationStackSetExecutionRole'." + +Resources: + AdministrationRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref AdministrationRoleName + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Action: + - sts:AssumeRole + Path: / + Policies: + - PolicyName: AssumeRole-AWSCloudFormationStackSetExecutionRole + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - sts:AssumeRole + Resource: + - !Sub 'arn:*:iam::*:role/${ExecutionRoleName}' + # Added execution role beyond the template example from the AWS docs + ExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref ExecutionRoleName + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + AWS: !Sub arn:aws:iam::${AWS::AccountId}:role/${AdministrationRoleName} + Action: + - sts:AssumeRole + Path: / + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AdministratorAccess + DependsOn: + - AdministrationRole