Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from localstack.aws.api import RequestContext, handler
from localstack.aws.api.cloudformation import (
Account,
CallAs,
Changes,
ChangeSetNameOrId,
Expand All @@ -29,6 +30,7 @@
DeletionMode,
DescribeChangeSetOutput,
DescribeStackEventsOutput,
DescribeStackInstanceOutput,
DescribeStackResourcesOutput,
DescribeStackSetOperationOutput,
DescribeStacksOutput,
Expand All @@ -47,10 +49,14 @@
NextToken,
Parameter,
PhysicalResourceId,
Region,
RetainExceptOnCreate,
RetainResources,
RoleARN,
RollbackConfiguration,
StackDriftStatus,
StackInstanceComprehensiveStatus,
StackInstanceDetailedStatus,
StackName,
StackNameOrId,
StackSetName,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,63 @@ 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,
aws_client,
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()}"

Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
@@ -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": "<stack-set-id:1>",
Expand All @@ -15,6 +15,21 @@
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
},
"describe-stack-instance": {
"Account": "111111111111",
"DriftStatus": "NOT_CHECKED",
"LastOperationId": "<uuid:2>",
"OrganizationalUnitId": "",
"ParameterOverrides": [],
"Region": "<region>",
"StackId": "<stack-id:1>",
"StackInstanceStatus": {
"DetailedStatus": "SUCCEEDED"
},
"StackSetId": "<stack-set-id:1>",
"Status": "CURRENT",
"StatusReason": "No updates are to be performed."
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading