• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / 20565403496

29 Dec 2025 05:11AM UTC coverage: 84.103% (-2.8%) from 86.921%
20565403496

Pull #13567

github

web-flow
Merge 4816837a5 into 2417384aa
Pull Request #13567: Update ASF APIs

67166 of 79862 relevant lines covered (84.1%)

0.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

20.77
/localstack-core/localstack/services/cloudformation/provider.py
1
import copy
1✔
2
import json
1✔
3
import logging
1✔
4
import re
1✔
5
from collections import defaultdict
1✔
6
from copy import deepcopy
1✔
7

8
from localstack import config
1✔
9
from localstack.aws.api import CommonServiceException, RequestContext, handler
1✔
10
from localstack.aws.api.cloudformation import (
1✔
11
    AlreadyExistsException,
12
    CallAs,
13
    ChangeSetNameOrId,
14
    ChangeSetNotFoundException,
15
    ChangeSetType,
16
    ClientRequestToken,
17
    CloudformationApi,
18
    CreateChangeSetInput,
19
    CreateChangeSetOutput,
20
    CreateStackInput,
21
    CreateStackInstancesInput,
22
    CreateStackInstancesOutput,
23
    CreateStackOutput,
24
    CreateStackSetInput,
25
    CreateStackSetOutput,
26
    DeleteChangeSetOutput,
27
    DeleteStackInstancesInput,
28
    DeleteStackInstancesOutput,
29
    DeleteStackSetOutput,
30
    DeletionMode,
31
    DescribeChangeSetOutput,
32
    DescribeStackEventsOutput,
33
    DescribeStackResourceOutput,
34
    DescribeStackResourcesOutput,
35
    DescribeStackSetOperationOutput,
36
    DescribeStackSetOutput,
37
    DescribeStacksOutput,
38
    DisableRollback,
39
    EnableTerminationProtection,
40
    ExecuteChangeSetOutput,
41
    ExecutionStatus,
42
    ExportName,
43
    GetTemplateOutput,
44
    GetTemplateSummaryInput,
45
    GetTemplateSummaryOutput,
46
    IncludePropertyValues,
47
    InsufficientCapabilitiesException,
48
    InvalidChangeSetStatusException,
49
    ListChangeSetsOutput,
50
    ListExportsOutput,
51
    ListImportsOutput,
52
    ListStackInstancesInput,
53
    ListStackInstancesOutput,
54
    ListStackResourcesOutput,
55
    ListStackSetsInput,
56
    ListStackSetsOutput,
57
    ListStacksOutput,
58
    ListTypesInput,
59
    ListTypesOutput,
60
    LogicalResourceId,
61
    NextToken,
62
    Parameter,
63
    PhysicalResourceId,
64
    RegisterTypeInput,
65
    RegisterTypeOutput,
66
    RegistryType,
67
    RetainExceptOnCreate,
68
    RetainResources,
69
    RoleARN,
70
    StackName,
71
    StackNameOrId,
72
    StackSetName,
73
    StackStatus,
74
    StackStatusFilter,
75
    TemplateParameter,
76
    TemplateStage,
77
    TypeSummary,
78
    UpdateStackInput,
79
    UpdateStackOutput,
80
    UpdateStackSetInput,
81
    UpdateStackSetOutput,
82
    UpdateTerminationProtectionOutput,
83
    ValidateTemplateInput,
84
    ValidateTemplateOutput,
85
)
86
from localstack.aws.connect import connect_to
1✔
87
from localstack.services.cloudformation import api_utils
1✔
88
from localstack.services.cloudformation.engine import parameters as param_resolver
1✔
89
from localstack.services.cloudformation.engine import template_deployer, template_preparer
1✔
90
from localstack.services.cloudformation.engine.entities import (
1✔
91
    Stack,
92
    StackChangeSet,
93
    StackInstance,
94
    StackSet,
95
)
96
from localstack.services.cloudformation.engine.parameters import mask_no_echo, strip_parameter_type
1✔
97
from localstack.services.cloudformation.engine.resource_ordering import (
1✔
98
    NoResourceInStack,
99
    order_resources,
100
)
101
from localstack.services.cloudformation.engine.template_deployer import (
1✔
102
    NoStackUpdates,
103
)
104
from localstack.services.cloudformation.engine.template_utils import resolve_stack_conditions
1✔
105
from localstack.services.cloudformation.engine.transformers import (
1✔
106
    FailedTransformationException,
107
)
108
from localstack.services.cloudformation.engine.validations import (
1✔
109
    DEFAULT_TEMPLATE_VALIDATIONS,
110
    ValidationError,
111
)
112
from localstack.services.cloudformation.resource_provider import (
1✔
113
    PRO_RESOURCE_PROVIDERS,
114
    ResourceProvider,
115
)
116
from localstack.services.cloudformation.stores import (
1✔
117
    cloudformation_stores,
118
    find_active_stack_by_name_or_id,
119
    find_change_set,
120
    find_stack,
121
    find_stack_by_id,
122
    get_cloudformation_store,
123
)
124
from localstack.services.plugins import ServiceLifecycleHook
1✔
125
from localstack.state import StateVisitor
1✔
126
from localstack.utils.collections import (
1✔
127
    remove_attributes,
128
    select_attributes,
129
    select_from_typed_dict,
130
)
131
from localstack.utils.json import clone
1✔
132
from localstack.utils.strings import long_uid, short_uid
1✔
133

134
LOG = logging.getLogger(__name__)
1✔
135

136
ARN_CHANGESET_REGEX = re.compile(
1✔
137
    r"arn:(aws|aws-us-gov|aws-cn):cloudformation:[-a-zA-Z0-9]+:\d{12}:changeSet/[a-zA-Z][-a-zA-Z0-9]*/[-a-zA-Z0-9:/._+]+"
138
)
139
ARN_STACK_REGEX = re.compile(
1✔
140
    r"arn:(aws|aws-us-gov|aws-cn):cloudformation:[-a-zA-Z0-9]+:\d{12}:stack/[a-zA-Z][-a-zA-Z0-9]*/[-a-zA-Z0-9:/._+]+"
141
)
142
ARN_STACK_SET_REGEX = re.compile(
1✔
143
    r"arn:(aws|aws-us-gov|aws-cn):cloudformation:[-a-zA-Z0-9]+:\d{12}:stack-set/[a-zA-Z][-a-zA-Z0-9]*/[-a-zA-Z0-9:/._+]+"
144
)
145

146

147
def clone_stack_params(stack_params):
1✔
148
    try:
×
149
        return clone(stack_params)
×
150
    except Exception as e:
×
151
        LOG.info("Unable to clone stack parameters: %s", e)
×
152
        return stack_params
×
153

154

155
def find_stack_instance(stack_set: StackSet, account: str, region: str):
1✔
156
    for instance in stack_set.stack_instances:
×
157
        if instance.metadata["Account"] == account and instance.metadata["Region"] == region:
×
158
            return instance
×
159
    return None
×
160

161

162
def stack_not_found_error(stack_name: str):
1✔
163
    # FIXME
164
    raise ValidationError(f"Stack with id {stack_name} does not exist")
×
165

166

167
def not_found_error(message: str):
1✔
168
    # FIXME
169
    raise ResourceNotFoundException(message)
×
170

171

172
class ResourceNotFoundException(CommonServiceException):
1✔
173
    def __init__(self, message=None):
1✔
174
        super().__init__("ResourceNotFoundException", status_code=404, message=message)
×
175

176

177
class InternalFailure(CommonServiceException):
1✔
178
    def __init__(self, message=None):
1✔
179
        super().__init__("InternalFailure", status_code=500, message=message, sender_fault=False)
×
180

181

182
class CloudformationProvider(CloudformationApi, ServiceLifecycleHook):
1✔
183
    def on_before_start(self):
1✔
184
        self._validate_config()
1✔
185

186
    def _validate_config(self):
1✔
187
        no_wait_value: int = 5
1✔
188
        try:
1✔
189
            no_wait_value = int(config.CFN_NO_WAIT_ITERATIONS or 5)
1✔
190
        except (TypeError, ValueError):
×
191
            LOG.warning(
×
192
                "You have set CFN_NO_WAIT_ITERATIONS to an invalid value: '%s'. It must be an integer greater or equal to 0. Using the default of 5",
193
                config.CFN_NO_WAIT_ITERATIONS,
194
            )
195

196
        if no_wait_value < 0:
1✔
197
            LOG.warning(
×
198
                "You have set CFN_NO_WAIT_ITERATIONS to an invalid value: '%s'. It must be an integer greater or equal to 0. Using the default of 5",
199
                config.CFN_NO_WAIT_ITERATIONS,
200
            )
201
            no_wait_value = 5
×
202

203
        # Set the configuration back
204
        config.CFN_NO_WAIT_ITERATIONS = no_wait_value
1✔
205

206
    def _stack_status_is_active(self, stack_status: str) -> bool:
1✔
207
        return stack_status not in [StackStatus.DELETE_COMPLETE]
1✔
208

209
    def accept_state_visitor(self, visitor: StateVisitor):
1✔
210
        visitor.visit(cloudformation_stores)
×
211

212
    @handler("CreateStack", expand=False)
1✔
213
    def create_stack(self, context: RequestContext, request: CreateStackInput) -> CreateStackOutput:
1✔
214
        # TODO: test what happens when both TemplateUrl and Body are specified
215
        state = get_cloudformation_store(context.account_id, context.region)
×
216

217
        stack_name = request.get("StackName")
×
218

219
        # get stacks by name
220
        active_stack_candidates = [
×
221
            s
222
            for s in state.stacks.values()
223
            if s.stack_name == stack_name and self._stack_status_is_active(s.status)
224
        ]
225

226
        # TODO: fix/implement this code path
227
        #   this needs more investigation how Cloudformation handles it (e.g. normal stack create or does it create a separate changeset?)
228
        # REVIEW_IN_PROGRESS is another special status
229
        # in this case existing changesets are set to obsolete and the stack is created
230
        # review_stack_candidates = [s for s in stack_candidates if s.status == StackStatus.REVIEW_IN_PROGRESS]
231
        # if review_stack_candidates:
232
        # set changesets to obsolete
233
        # for cs in review_stack_candidates[0].change_sets:
234
        #     cs.execution_status = ExecutionStatus.OBSOLETE
235

236
        if active_stack_candidates:
×
237
            raise AlreadyExistsException(f"Stack [{stack_name}] already exists")
×
238

239
        template_body = request.get("TemplateBody") or ""
×
240
        if len(template_body) > 51200:
×
241
            raise ValidationError(
×
242
                f"1 validation error detected: Value '{request['TemplateBody']}' at 'templateBody' "
243
                "failed to satisfy constraint: Member must have length less than or equal to 51200"
244
            )
245
        api_utils.prepare_template_body(request)  # TODO: avoid mutating request directly
×
246

247
        template = template_preparer.parse_template(request["TemplateBody"])
×
248

249
        stack_name = template["StackName"] = request.get("StackName")
×
250
        if api_utils.validate_stack_name(stack_name) is False:
×
251
            raise ValidationError(
×
252
                f"1 validation error detected: Value '{stack_name}' at 'stackName' failed to satisfy constraint:\
253
                Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*|arn:[-a-zA-Z0-9:/._+]*"
254
            )
255

256
        if (
×
257
            "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", [])
258
            and "Transform" in template.keys()
259
        ):
260
            raise InsufficientCapabilitiesException(
×
261
                "Requires capabilities : [CAPABILITY_AUTO_EXPAND]"
262
            )
263

264
        # resolve stack parameters
265
        new_parameters = param_resolver.convert_stack_parameters_to_dict(request.get("Parameters"))
×
266
        parameter_declarations = param_resolver.extract_stack_parameter_declarations(template)
×
267
        resolved_parameters = param_resolver.resolve_parameters(
×
268
            account_id=context.account_id,
269
            region_name=context.region,
270
            parameter_declarations=parameter_declarations,
271
            new_parameters=new_parameters,
272
            old_parameters={},
273
        )
274

275
        stack = Stack(context.account_id, context.region, request, template)
×
276

277
        try:
×
278
            template = template_preparer.transform_template(
×
279
                context.account_id,
280
                context.region,
281
                template,
282
                stack.stack_name,
283
                stack.resources,
284
                stack.mappings,
285
                {},  # TODO
286
                resolved_parameters,
287
            )
288
        except FailedTransformationException as e:
×
289
            stack.add_stack_event(
×
290
                stack.stack_name,
291
                stack.stack_id,
292
                status="ROLLBACK_IN_PROGRESS",
293
                status_reason=e.message,
294
            )
295
            stack.set_stack_status("ROLLBACK_COMPLETE")
×
296
            state.stacks[stack.stack_id] = stack
×
297
            return CreateStackOutput(StackId=stack.stack_id)
×
298

299
        # HACK: recreate the stack (including all of its confusing processes in the __init__ method
300
        # to set the stack template to be the transformed template, rather than the untransformed
301
        # template
302
        stack = Stack(context.account_id, context.region, request, template)
×
303

304
        # perform basic static analysis on the template
305
        for validation_fn in DEFAULT_TEMPLATE_VALIDATIONS:
×
306
            validation_fn(template)
×
307

308
        # resolve conditions
309
        raw_conditions = template.get("Conditions", {})
×
310
        resolved_stack_conditions = resolve_stack_conditions(
×
311
            account_id=context.account_id,
312
            region_name=context.region,
313
            conditions=raw_conditions,
314
            parameters=resolved_parameters,
315
            mappings=stack.mappings,
316
            stack_name=stack_name,
317
        )
318
        stack.set_resolved_stack_conditions(resolved_stack_conditions)
×
319

320
        stack.set_resolved_parameters(resolved_parameters)
×
321
        stack.template_body = template_body
×
322
        state.stacks[stack.stack_id] = stack
×
323
        LOG.debug(
×
324
            'Creating stack "%s" with %s resources ...',
325
            stack.stack_name,
326
            len(stack.template_resources),
327
        )
328
        deployer = template_deployer.TemplateDeployer(context.account_id, context.region, stack)
×
329
        try:
×
330
            deployer.deploy_stack()
×
331
        except Exception as e:
×
332
            stack.set_stack_status("CREATE_FAILED")
×
333
            msg = f'Unable to create stack "{stack.stack_name}": {e}'
×
334
            LOG.error("%s", exc_info=LOG.isEnabledFor(logging.DEBUG))
×
335
            raise ValidationError(msg) from e
×
336

337
        return CreateStackOutput(StackId=stack.stack_id)
×
338

339
    @handler("DeleteStack")
1✔
340
    def delete_stack(
1✔
341
        self,
342
        context: RequestContext,
343
        stack_name: StackName,
344
        retain_resources: RetainResources = None,
345
        role_arn: RoleARN = None,
346
        client_request_token: ClientRequestToken = None,
347
        deletion_mode: DeletionMode = None,
348
        **kwargs,
349
    ) -> None:
350
        stack = find_active_stack_by_name_or_id(context.account_id, context.region, stack_name)
×
351
        if not stack:
×
352
            # aws will silently ignore invalid stack names - we should do the same
353
            return
×
354
        deployer = template_deployer.TemplateDeployer(context.account_id, context.region, stack)
×
355
        deployer.delete_stack()
×
356

357
    @handler("UpdateStack", expand=False)
1✔
358
    def update_stack(
1✔
359
        self,
360
        context: RequestContext,
361
        request: UpdateStackInput,
362
    ) -> UpdateStackOutput:
363
        stack_name = request.get("StackName")
×
364
        stack = find_stack(context.account_id, context.region, stack_name)
×
365
        if not stack:
×
366
            return not_found_error(f'Unable to update non-existing stack "{stack_name}"')
×
367

368
        api_utils.prepare_template_body(request)
×
369
        template = template_preparer.parse_template(request["TemplateBody"])
×
370

371
        if (
×
372
            "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", [])
373
            and "Transform" in template.keys()
374
        ):
375
            raise InsufficientCapabilitiesException(
×
376
                "Requires capabilities : [CAPABILITY_AUTO_EXPAND]"
377
            )
378

379
        new_parameters: dict[str, Parameter] = param_resolver.convert_stack_parameters_to_dict(
×
380
            request.get("Parameters")
381
        )
382
        parameter_declarations = param_resolver.extract_stack_parameter_declarations(template)
×
383
        resolved_parameters = param_resolver.resolve_parameters(
×
384
            account_id=context.account_id,
385
            region_name=context.region,
386
            parameter_declarations=parameter_declarations,
387
            new_parameters=new_parameters,
388
            old_parameters=stack.resolved_parameters,
389
        )
390

391
        resolved_stack_conditions = resolve_stack_conditions(
×
392
            account_id=context.account_id,
393
            region_name=context.region,
394
            conditions=template.get("Conditions", {}),
395
            parameters=resolved_parameters,
396
            mappings=template.get("Mappings", {}),
397
            stack_name=stack_name,
398
        )
399

400
        raw_new_template = copy.deepcopy(template)
×
401
        try:
×
402
            template = template_preparer.transform_template(
×
403
                context.account_id,
404
                context.region,
405
                template,
406
                stack.stack_name,
407
                stack.resources,
408
                stack.mappings,
409
                resolved_stack_conditions,
410
                resolved_parameters,
411
            )
412
            processed_template = copy.deepcopy(
×
413
                template
414
            )  # copying it here since it's being mutated somewhere downstream
415
        except FailedTransformationException as e:
×
416
            stack.add_stack_event(
×
417
                stack.stack_name,
418
                stack.stack_id,
419
                status="ROLLBACK_IN_PROGRESS",
420
                status_reason=e.message,
421
            )
422
            stack.set_stack_status("ROLLBACK_COMPLETE")
×
423
            return CreateStackOutput(StackId=stack.stack_id)
×
424

425
        # perform basic static analysis on the template
426
        for validation_fn in DEFAULT_TEMPLATE_VALIDATIONS:
×
427
            validation_fn(template)
×
428

429
        # update the template
430
        stack.template_original = template
×
431

432
        deployer = template_deployer.TemplateDeployer(context.account_id, context.region, stack)
×
433
        # TODO: there shouldn't be a "new" stack on update
434
        new_stack = Stack(
×
435
            context.account_id, context.region, request, template, request["TemplateBody"]
436
        )
437
        new_stack.set_resolved_parameters(resolved_parameters)
×
438
        stack.set_resolved_parameters(resolved_parameters)
×
439
        stack.set_resolved_stack_conditions(resolved_stack_conditions)
×
440
        try:
×
441
            deployer.update_stack(new_stack)
×
442
        except NoStackUpdates as e:
×
443
            stack.set_stack_status("UPDATE_COMPLETE")
×
444
            if raw_new_template != processed_template:
×
445
                # processed templates seem to never return an exception here
446
                return UpdateStackOutput(StackId=stack.stack_id)
×
447
            raise ValidationError(str(e))
×
448
        except Exception as e:
×
449
            stack.set_stack_status("UPDATE_FAILED")
×
450
            msg = f'Unable to update stack "{stack_name}": {e}'
×
451
            LOG.error("%s", msg, exc_info=LOG.isEnabledFor(logging.DEBUG))
×
452
            raise ValidationError(msg) from e
×
453

454
        return UpdateStackOutput(StackId=stack.stack_id)
×
455

456
    @handler("DescribeStacks")
1✔
457
    def describe_stacks(
1✔
458
        self,
459
        context: RequestContext,
460
        stack_name: StackName = None,
461
        next_token: NextToken = None,
462
        **kwargs,
463
    ) -> DescribeStacksOutput:
464
        # TODO: test & implement pagination
465
        state = get_cloudformation_store(context.account_id, context.region)
×
466

467
        if stack_name:
×
468
            if ARN_STACK_REGEX.match(stack_name):
×
469
                # we can get the stack directly since we index the store by ARN/stackID
470
                stack = state.stacks.get(stack_name)
×
471
                stacks = [stack.describe_details()] if stack else []
×
472
            else:
473
                # otherwise we have to find the active stack with the given name
474
                stack_candidates: list[Stack] = [
×
475
                    s for stack_arn, s in state.stacks.items() if s.stack_name == stack_name
476
                ]
477
                active_stack_candidates = [
×
478
                    s for s in stack_candidates if self._stack_status_is_active(s.status)
479
                ]
480
                stacks = [s.describe_details() for s in active_stack_candidates]
×
481
        else:
482
            # return all active stacks
483
            stack_list = list(state.stacks.values())
×
484
            stacks = [
×
485
                s.describe_details() for s in stack_list if self._stack_status_is_active(s.status)
486
            ]
487

488
        if stack_name and not stacks:
×
489
            raise ValidationError(f"Stack with id {stack_name} does not exist")
×
490

491
        return DescribeStacksOutput(Stacks=stacks)
×
492

493
    @handler("ListStacks")
1✔
494
    def list_stacks(
1✔
495
        self,
496
        context: RequestContext,
497
        next_token: NextToken = None,
498
        stack_status_filter: StackStatusFilter = None,
499
        **kwargs,
500
    ) -> ListStacksOutput:
501
        state = get_cloudformation_store(context.account_id, context.region)
×
502

503
        stacks = [
×
504
            s.describe_details()
505
            for s in state.stacks.values()
506
            if not stack_status_filter or s.status in stack_status_filter
507
        ]
508

509
        attrs = [
×
510
            "StackId",
511
            "StackName",
512
            "TemplateDescription",
513
            "CreationTime",
514
            "LastUpdatedTime",
515
            "DeletionTime",
516
            "StackStatus",
517
            "StackStatusReason",
518
            "ParentId",
519
            "RootId",
520
            "DriftInformation",
521
        ]
522
        stacks = [select_attributes(stack, attrs) for stack in stacks]
×
523
        return ListStacksOutput(StackSummaries=stacks)
×
524

525
    @handler("GetTemplate")
1✔
526
    def get_template(
1✔
527
        self,
528
        context: RequestContext,
529
        stack_name: StackName = None,
530
        change_set_name: ChangeSetNameOrId = None,
531
        template_stage: TemplateStage = None,
532
        **kwargs,
533
    ) -> GetTemplateOutput:
534
        if change_set_name:
×
535
            stack = find_change_set(
×
536
                context.account_id, context.region, stack_name=stack_name, cs_name=change_set_name
537
            )
538
        else:
539
            stack = find_stack(context.account_id, context.region, stack_name)
×
540
        if not stack:
×
541
            return stack_not_found_error(stack_name)
×
542

543
        if template_stage == TemplateStage.Processed and "Transform" in stack.template_body:
×
544
            copy_template = clone(stack.template_original)
×
545
            for key in [
×
546
                "ChangeSetName",
547
                "StackName",
548
                "StackId",
549
                "Transform",
550
                "Conditions",
551
                "Mappings",
552
            ]:
553
                copy_template.pop(key, None)
×
554
            for key in ["Parameters", "Outputs"]:
×
555
                if key in copy_template and not copy_template[key]:
×
556
                    copy_template.pop(key)
×
557
            for resource in copy_template.get("Resources", {}).values():
×
558
                resource.pop("LogicalResourceId", None)
×
559
            template_body = json.dumps(copy_template)
×
560
        else:
561
            template_body = stack.template_body
×
562

563
        return GetTemplateOutput(
×
564
            TemplateBody=template_body,
565
            StagesAvailable=[TemplateStage.Original, TemplateStage.Processed],
566
        )
567

568
    @handler("GetTemplateSummary", expand=False)
1✔
569
    def get_template_summary(
1✔
570
        self,
571
        context: RequestContext,
572
        request: GetTemplateSummaryInput,
573
    ) -> GetTemplateSummaryOutput:
574
        stack_name = request.get("StackName")
×
575

576
        if stack_name:
×
577
            stack = find_stack(context.account_id, context.region, stack_name)
×
578
            if not stack:
×
579
                return stack_not_found_error(stack_name)
×
580
            template = stack.template
×
581
        else:
582
            api_utils.prepare_template_body(request)
×
583
            template = template_preparer.parse_template(request["TemplateBody"])
×
584
            request["StackName"] = "tmp-stack"
×
585
            stack = Stack(context.account_id, context.region, request, template)
×
586

587
        result: GetTemplateSummaryOutput = stack.describe_details()
×
588

589
        # build parameter declarations
590
        result["Parameters"] = list(
×
591
            param_resolver.extract_stack_parameter_declarations(template).values()
592
        )
593

594
        id_summaries = defaultdict(list)
×
595
        for resource_id, resource in stack.template_resources.items():
×
596
            res_type = resource["Type"]
×
597
            id_summaries[res_type].append(resource_id)
×
598

599
        result["ResourceTypes"] = list(id_summaries.keys())
×
600
        result["ResourceIdentifierSummaries"] = [
×
601
            {"ResourceType": key, "LogicalResourceIds": values}
602
            for key, values in id_summaries.items()
603
        ]
604
        result["Metadata"] = stack.template.get("Metadata")
×
605
        result["Version"] = stack.template.get("AWSTemplateFormatVersion", "2010-09-09")
×
606
        # these do not appear in the output
607
        result.pop("Capabilities", None)
×
608

609
        return select_from_typed_dict(GetTemplateSummaryOutput, result)
×
610

611
    def update_termination_protection(
1✔
612
        self,
613
        context: RequestContext,
614
        enable_termination_protection: EnableTerminationProtection,
615
        stack_name: StackNameOrId,
616
        **kwargs,
617
    ) -> UpdateTerminationProtectionOutput:
618
        stack = find_stack(context.account_id, context.region, stack_name)
×
619
        if not stack:
×
620
            raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
621
        stack.metadata["EnableTerminationProtection"] = enable_termination_protection
×
622
        return UpdateTerminationProtectionOutput(StackId=stack.stack_id)
×
623

624
    @handler("CreateChangeSet", expand=False)
1✔
625
    def create_change_set(
1✔
626
        self, context: RequestContext, request: CreateChangeSetInput
627
    ) -> CreateChangeSetOutput:
628
        state = get_cloudformation_store(context.account_id, context.region)
×
629

630
        req_params = request
×
631
        change_set_type = req_params.get("ChangeSetType", "UPDATE")
×
632
        stack_name = req_params.get("StackName")
×
633
        if not stack_name:
×
634
            raise ValidationError("Member must have length greater than or equal to 1")
×
635
        change_set_name = req_params.get("ChangeSetName")
×
636
        template_body = req_params.get("TemplateBody")
×
637
        # s3 or secretsmanager url
638
        template_url = req_params.get("TemplateURL")
×
639

640
        # validate and resolve template
641
        if template_body and template_url:
×
642
            raise ValidationError(
×
643
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
644
            )  # TODO: check proper message
645

646
        if not template_body and not template_url:
×
647
            raise ValidationError(
×
648
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
649
            )  # TODO: check proper message
650

651
        api_utils.prepare_template_body(
×
652
            req_params
653
        )  # TODO: function has too many unclear responsibilities
654
        if not template_body:
×
655
            template_body = req_params[
×
656
                "TemplateBody"
657
            ]  # should then have been set by prepare_template_body
658
        template = template_preparer.parse_template(req_params["TemplateBody"])
×
659

660
        del req_params["TemplateBody"]  # TODO: stop mutating req_params
×
661
        template["StackName"] = stack_name
×
662
        # TODO: validate with AWS what this is actually doing?
663
        template["ChangeSetName"] = change_set_name
×
664

665
        # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing
666
        # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet)
667
        if ARN_STACK_REGEX.match(stack_name):
×
668
            if not (stack := state.stacks.get(stack_name)):
×
669
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
670
        else:
671
            # stack name specified, so fetch the stack by name
672
            stack_candidates: list[Stack] = [
×
673
                s for stack_arn, s in state.stacks.items() if s.stack_name == stack_name
674
            ]
675
            active_stack_candidates = [
×
676
                s for s in stack_candidates if self._stack_status_is_active(s.status)
677
            ]
678

679
            # on a CREATE an empty Stack should be generated if we didn't find an active one
680
            if not active_stack_candidates and change_set_type == ChangeSetType.CREATE:
×
681
                empty_stack_template = dict(template)
×
682
                empty_stack_template["Resources"] = {}
×
683
                req_params_copy = clone_stack_params(req_params)
×
684
                stack = Stack(
×
685
                    context.account_id,
686
                    context.region,
687
                    req_params_copy,
688
                    empty_stack_template,
689
                    template_body=template_body,
690
                )
691
                state.stacks[stack.stack_id] = stack
×
692
                stack.set_stack_status("REVIEW_IN_PROGRESS")
×
693
            else:
694
                if not active_stack_candidates:
×
695
                    raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
696
                stack = active_stack_candidates[0]
×
697

698
        # TODO: test if rollback status is allowed as well
699
        if (
×
700
            change_set_type == ChangeSetType.CREATE
701
            and stack.status != StackStatus.REVIEW_IN_PROGRESS
702
        ):
703
            raise ValidationError(
×
704
                f"Stack [{stack_name}] already exists and cannot be created again with the changeSet [{change_set_name}]."
705
            )
706

707
        old_parameters: dict[str, Parameter] = {}
×
708
        match change_set_type:
×
709
            case ChangeSetType.UPDATE:
×
710
                # add changeset to existing stack
711
                old_parameters = {
×
712
                    k: mask_no_echo(strip_parameter_type(v))
713
                    for k, v in stack.resolved_parameters.items()
714
                }
715
            case ChangeSetType.IMPORT:
×
716
                raise NotImplementedError()  # TODO: implement importing resources
717
            case ChangeSetType.CREATE:
×
718
                pass
×
719
            case _:
×
720
                msg = (
×
721
                    f"1 validation error detected: Value '{change_set_type}' at 'changeSetType' failed to satisfy "
722
                    f"constraint: Member must satisfy enum value set: [IMPORT, UPDATE, CREATE] "
723
                )
724
                raise ValidationError(msg)
×
725

726
        # resolve parameters
727
        new_parameters: dict[str, Parameter] = param_resolver.convert_stack_parameters_to_dict(
×
728
            request.get("Parameters")
729
        )
730
        parameter_declarations = param_resolver.extract_stack_parameter_declarations(template)
×
731
        resolved_parameters = param_resolver.resolve_parameters(
×
732
            account_id=context.account_id,
733
            region_name=context.region,
734
            parameter_declarations=parameter_declarations,
735
            new_parameters=new_parameters,
736
            old_parameters=old_parameters,
737
        )
738

739
        # TODO: remove this when fixing Stack.resources and transformation order
740
        #   currently we need to create a stack with existing resources + parameters so that resolve refs recursively in here will work.
741
        #   The correct way to do it would be at a later stage anyway just like a normal intrinsic function
742
        req_params_copy = clone_stack_params(req_params)
×
743
        temp_stack = Stack(context.account_id, context.region, req_params_copy, template)
×
744
        temp_stack.set_resolved_parameters(resolved_parameters)
×
745

746
        # TODO: everything below should be async
747
        # apply template transformations
748
        transformed_template = template_preparer.transform_template(
×
749
            context.account_id,
750
            context.region,
751
            template,
752
            stack_name=temp_stack.stack_name,
753
            resources=temp_stack.resources,
754
            mappings=temp_stack.mappings,
755
            conditions={},  # TODO: we don't have any resolved conditions yet at this point but we need the conditions because of the samtranslator...
756
            resolved_parameters=resolved_parameters,
757
        )
758

759
        # perform basic static analysis on the template
760
        for validation_fn in DEFAULT_TEMPLATE_VALIDATIONS:
×
761
            validation_fn(template)
×
762

763
        # create change set for the stack and apply changes
764
        change_set = StackChangeSet(
×
765
            context.account_id, context.region, stack, req_params, transformed_template
766
        )
767
        # only set parameters for the changeset, then switch to stack on execute_change_set
768
        change_set.set_resolved_parameters(resolved_parameters)
×
769
        change_set.template_body = template_body
×
770

771
        # TODO: evaluate conditions
772
        raw_conditions = transformed_template.get("Conditions", {})
×
773
        resolved_stack_conditions = resolve_stack_conditions(
×
774
            account_id=context.account_id,
775
            region_name=context.region,
776
            conditions=raw_conditions,
777
            parameters=resolved_parameters,
778
            mappings=temp_stack.mappings,
779
            stack_name=stack_name,
780
        )
781
        change_set.set_resolved_stack_conditions(resolved_stack_conditions)
×
782

783
        # a bit gross but use the template ordering to validate missing resources
784
        try:
×
785
            order_resources(
×
786
                transformed_template["Resources"],
787
                resolved_parameters=resolved_parameters,
788
                resolved_conditions=resolved_stack_conditions,
789
            )
790
        except NoResourceInStack as e:
×
791
            raise ValidationError(str(e)) from e
×
792

793
        deployer = template_deployer.TemplateDeployer(
×
794
            context.account_id, context.region, change_set
795
        )
796
        changes = deployer.construct_changes(
×
797
            stack,
798
            change_set,
799
            change_set_id=change_set.change_set_id,
800
            append_to_changeset=True,
801
            filter_unchanged_resources=True,
802
        )
803
        stack.change_sets.append(change_set)
×
804
        if not changes:
×
805
            change_set.metadata["Status"] = "FAILED"
×
806
            change_set.metadata["ExecutionStatus"] = "UNAVAILABLE"
×
807
            change_set.metadata["StatusReason"] = (
×
808
                "The submitted information didn't contain changes. Submit different information to create a change set."
809
            )
810
        else:
811
            change_set.metadata["Status"] = (
×
812
                "CREATE_COMPLETE"  # technically for some time this should first be CREATE_PENDING
813
            )
814
            change_set.metadata["ExecutionStatus"] = (
×
815
                "AVAILABLE"  # technically for some time this should first be UNAVAILABLE
816
            )
817

818
        return CreateChangeSetOutput(StackId=change_set.stack_id, Id=change_set.change_set_id)
×
819

820
    @handler("DescribeChangeSet")
1✔
821
    def describe_change_set(
1✔
822
        self,
823
        context: RequestContext,
824
        change_set_name: ChangeSetNameOrId,
825
        stack_name: StackNameOrId = None,
826
        next_token: NextToken = None,
827
        include_property_values: IncludePropertyValues = None,
828
        **kwargs,
829
    ) -> DescribeChangeSetOutput:
830
        # TODO add support for include_property_values
831
        # only relevant if change_set_name isn't an ARN
832
        if not ARN_CHANGESET_REGEX.match(change_set_name):
×
833
            if not stack_name:
×
834
                raise ValidationError(
×
835
                    "StackName must be specified if ChangeSetName is not specified as an ARN."
836
                )
837

838
            stack = find_stack(context.account_id, context.region, stack_name)
×
839
            if not stack:
×
840
                raise ValidationError(f"Stack [{stack_name}] does not exist")
×
841

842
        change_set = find_change_set(
×
843
            context.account_id, context.region, change_set_name, stack_name=stack_name
844
        )
845
        if not change_set:
×
846
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
×
847

848
        attrs = [
×
849
            "ChangeSetType",
850
            "StackStatus",
851
            "LastUpdatedTime",
852
            "DisableRollback",
853
            "EnableTerminationProtection",
854
            "Transform",
855
        ]
856
        result = remove_attributes(deepcopy(change_set.metadata), attrs)
×
857
        # TODO: replace this patch with a better solution
858
        result["Parameters"] = [
×
859
            mask_no_echo(strip_parameter_type(p)) for p in result.get("Parameters", [])
860
        ]
861
        return result
×
862

863
    @handler("DeleteChangeSet")
1✔
864
    def delete_change_set(
1✔
865
        self,
866
        context: RequestContext,
867
        change_set_name: ChangeSetNameOrId,
868
        stack_name: StackNameOrId = None,
869
        **kwargs,
870
    ) -> DeleteChangeSetOutput:
871
        # only relevant if change_set_name isn't an ARN
872
        if not ARN_CHANGESET_REGEX.match(change_set_name):
×
873
            if not stack_name:
×
874
                raise ValidationError(
×
875
                    "StackName must be specified if ChangeSetName is not specified as an ARN."
876
                )
877

878
            stack = find_stack(context.account_id, context.region, stack_name)
×
879
            if not stack:
×
880
                raise ValidationError(f"Stack [{stack_name}] does not exist")
×
881

882
        change_set = find_change_set(
×
883
            context.account_id, context.region, change_set_name, stack_name=stack_name
884
        )
885
        if not change_set:
×
886
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
×
887
        change_set.stack.change_sets = [
×
888
            cs
889
            for cs in change_set.stack.change_sets
890
            if change_set_name not in (cs.change_set_name, cs.change_set_id)
891
        ]
892
        return DeleteChangeSetOutput()
×
893

894
    @handler("ExecuteChangeSet")
1✔
895
    def execute_change_set(
1✔
896
        self,
897
        context: RequestContext,
898
        change_set_name: ChangeSetNameOrId,
899
        stack_name: StackNameOrId = None,
900
        client_request_token: ClientRequestToken = None,
901
        disable_rollback: DisableRollback = None,
902
        retain_except_on_create: RetainExceptOnCreate = None,
903
        **kwargs,
904
    ) -> ExecuteChangeSetOutput:
905
        change_set = find_change_set(
×
906
            context.account_id,
907
            context.region,
908
            change_set_name,
909
            stack_name=stack_name,
910
            active_only=True,
911
        )
912
        if not change_set:
×
913
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
×
914
        if change_set.metadata.get("ExecutionStatus") != ExecutionStatus.AVAILABLE:
×
915
            LOG.debug("Change set %s not in execution status 'AVAILABLE'", change_set_name)
×
916
            raise InvalidChangeSetStatusException(
×
917
                f"ChangeSet [{change_set.metadata['ChangeSetId']}] cannot be executed in its current status of [{change_set.metadata.get('Status')}]"
918
            )
919
        stack_name = change_set.stack.stack_name
×
920
        LOG.debug(
×
921
            'Executing change set "%s" for stack "%s" with %s resources ...',
922
            change_set_name,
923
            stack_name,
924
            len(change_set.template_resources),
925
        )
926
        deployer = template_deployer.TemplateDeployer(
×
927
            context.account_id, context.region, change_set.stack
928
        )
929
        try:
×
930
            deployer.apply_change_set(change_set)
×
931
            change_set.stack.metadata["ChangeSetId"] = change_set.change_set_id
×
932
        except NoStackUpdates:
×
933
            # TODO: parity-check if this exception should be re-raised or swallowed
934
            raise ValidationError("No updates to be performed for stack change set")
×
935

936
        return ExecuteChangeSetOutput()
×
937

938
    @handler("ListChangeSets")
1✔
939
    def list_change_sets(
1✔
940
        self,
941
        context: RequestContext,
942
        stack_name: StackNameOrId,
943
        next_token: NextToken = None,
944
        **kwargs,
945
    ) -> ListChangeSetsOutput:
946
        stack = find_stack(context.account_id, context.region, stack_name)
×
947
        if not stack:
×
948
            return not_found_error(f'Unable to find stack "{stack_name}"')
×
949
        result = [cs.metadata for cs in stack.change_sets]
×
950
        return ListChangeSetsOutput(Summaries=result)
×
951

952
    @handler("ListExports")
1✔
953
    def list_exports(
1✔
954
        self, context: RequestContext, next_token: NextToken = None, **kwargs
955
    ) -> ListExportsOutput:
956
        state = get_cloudformation_store(context.account_id, context.region)
×
957
        return ListExportsOutput(Exports=state.exports.values())
×
958

959
    @handler("ListImports")
1✔
960
    def list_imports(
1✔
961
        self,
962
        context: RequestContext,
963
        export_name: ExportName,
964
        next_token: NextToken = None,
965
        **kwargs,
966
    ) -> ListImportsOutput:
967
        state = get_cloudformation_store(context.account_id, context.region)
×
968

969
        importing_stack_names = []
×
970
        for stack in state.stacks.values():
×
971
            if export_name in stack.imports:
×
972
                importing_stack_names.append(stack.stack_name)
×
973

974
        return ListImportsOutput(Imports=importing_stack_names)
×
975

976
    @handler("DescribeStackEvents")
1✔
977
    def describe_stack_events(
1✔
978
        self,
979
        context: RequestContext,
980
        stack_name: StackName,
981
        next_token: NextToken | None = None,
982
        **kwargs,
983
    ) -> DescribeStackEventsOutput:
984
        if stack_name is None:
×
985
            raise ValidationError(
×
986
                "1 validation error detected: Value null at 'stackName' failed to satisfy constraint: Member must not be null"
987
            )
988

989
        stack = find_active_stack_by_name_or_id(context.account_id, context.region, stack_name)
×
990
        if not stack:
×
991
            stack = find_stack_by_id(
×
992
                account_id=context.account_id, region_name=context.region, stack_id=stack_name
993
            )
994
        if not stack:
×
995
            raise ValidationError(f"Stack [{stack_name}] does not exist")
×
996
        return DescribeStackEventsOutput(StackEvents=stack.events)
×
997

998
    @handler("DescribeStackResource")
1✔
999
    def describe_stack_resource(
1✔
1000
        self,
1001
        context: RequestContext,
1002
        stack_name: StackName,
1003
        logical_resource_id: LogicalResourceId,
1004
        **kwargs,
1005
    ) -> DescribeStackResourceOutput:
1006
        stack = find_stack(context.account_id, context.region, stack_name)
×
1007

1008
        if not stack:
×
1009
            return stack_not_found_error(stack_name)
×
1010

1011
        try:
×
1012
            details = stack.resource_status(logical_resource_id)
×
1013
        except Exception as e:
×
1014
            if "Unable to find details" in str(e):
×
1015
                raise ValidationError(
×
1016
                    f"Resource {logical_resource_id} does not exist for stack {stack_name}"
1017
                )
1018
            raise
×
1019

1020
        return DescribeStackResourceOutput(StackResourceDetail=details)
×
1021

1022
    @handler("DescribeStackResources")
1✔
1023
    def describe_stack_resources(
1✔
1024
        self,
1025
        context: RequestContext,
1026
        stack_name: StackName = None,
1027
        logical_resource_id: LogicalResourceId = None,
1028
        physical_resource_id: PhysicalResourceId = None,
1029
        **kwargs,
1030
    ) -> DescribeStackResourcesOutput:
1031
        if physical_resource_id and stack_name:
×
1032
            raise ValidationError("Cannot specify both StackName and PhysicalResourceId")
×
1033
        # TODO: filter stack by PhysicalResourceId!
1034
        stack = find_stack(context.account_id, context.region, stack_name)
×
1035
        if not stack:
×
1036
            return stack_not_found_error(stack_name)
×
1037
        statuses = [
×
1038
            res_status
1039
            for res_id, res_status in stack.resource_states.items()
1040
            if logical_resource_id in [res_id, None]
1041
        ]
1042
        for status in statuses:
×
1043
            status.setdefault("DriftInformation", {"StackResourceDriftStatus": "NOT_CHECKED"})
×
1044
        return DescribeStackResourcesOutput(StackResources=statuses)
×
1045

1046
    @handler("ListStackResources")
1✔
1047
    def list_stack_resources(
1✔
1048
        self, context: RequestContext, stack_name: StackName, next_token: NextToken = None, **kwargs
1049
    ) -> ListStackResourcesOutput:
1050
        result = self.describe_stack_resources(context, stack_name)
×
1051

1052
        resources = deepcopy(result.get("StackResources", []))
×
1053
        for resource in resources:
×
1054
            attrs = ["StackName", "StackId", "Timestamp", "PreviousResourceStatus"]
×
1055
            remove_attributes(resource, attrs)
×
1056

1057
        return ListStackResourcesOutput(StackResourceSummaries=resources)
×
1058

1059
    @handler("ValidateTemplate", expand=False)
1✔
1060
    def validate_template(
1✔
1061
        self, context: RequestContext, request: ValidateTemplateInput
1062
    ) -> ValidateTemplateOutput:
1063
        try:
1✔
1064
            # TODO implement actual validation logic
1065
            template_body = api_utils.get_template_body(request)
1✔
1066
            valid_template = json.loads(template_preparer.template_to_json(template_body))
1✔
1067

1068
            parameters = [
1✔
1069
                TemplateParameter(
1070
                    ParameterKey=k,
1071
                    DefaultValue=v.get("Default", ""),
1072
                    NoEcho=v.get("NoEcho", False),
1073
                    Description=v.get("Description", ""),
1074
                )
1075
                for k, v in valid_template.get("Parameters", {}).items()
1076
            ]
1077

1078
            return ValidateTemplateOutput(
1✔
1079
                Description=valid_template.get("Description"), Parameters=parameters
1080
            )
1081
        except Exception as e:
1✔
1082
            LOG.error("Error validating template", exc_info=LOG.isEnabledFor(logging.DEBUG))
1✔
1083
            raise ValidationError("Template Validation Error") from e
1✔
1084

1085
    # =======================================
1086
    # =============  Stack Set  =============
1087
    # =======================================
1088

1089
    @handler("CreateStackSet", expand=False)
1✔
1090
    def create_stack_set(
1✔
1091
        self, context: RequestContext, request: CreateStackSetInput
1092
    ) -> CreateStackSetOutput:
1093
        state = get_cloudformation_store(context.account_id, context.region)
×
1094
        stack_set = StackSet(request)
×
1095
        stack_set_id = f"{stack_set.stack_set_name}:{long_uid()}"
×
1096
        stack_set.metadata["StackSetId"] = stack_set_id
×
1097
        state.stack_sets[stack_set_id] = stack_set
×
1098

1099
        return CreateStackSetOutput(StackSetId=stack_set_id)
×
1100

1101
    @handler("DescribeStackSetOperation")
1✔
1102
    def describe_stack_set_operation(
1✔
1103
        self,
1104
        context: RequestContext,
1105
        stack_set_name: StackSetName,
1106
        operation_id: ClientRequestToken,
1107
        call_as: CallAs = None,
1108
        **kwargs,
1109
    ) -> DescribeStackSetOperationOutput:
1110
        state = get_cloudformation_store(context.account_id, context.region)
×
1111

1112
        set_name = stack_set_name
×
1113

1114
        stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name]
×
1115
        if not stack_set:
×
1116
            return not_found_error(f'Unable to find stack set "{set_name}"')
×
1117
        stack_set = stack_set[0]
×
1118
        result = stack_set.operations.get(operation_id)
×
1119
        if not result:
×
1120
            LOG.debug(
×
1121
                'Unable to find operation ID "%s" for stack set "%s" in list: %s',
1122
                operation_id,
1123
                set_name,
1124
                list(stack_set.operations.keys()),
1125
            )
1126
            return not_found_error(
×
1127
                f'Unable to find operation ID "{operation_id}" for stack set "{set_name}"'
1128
            )
1129

1130
        return DescribeStackSetOperationOutput(StackSetOperation=result)
×
1131

1132
    @handler("DescribeStackSet")
1✔
1133
    def describe_stack_set(
1✔
1134
        self,
1135
        context: RequestContext,
1136
        stack_set_name: StackSetName,
1137
        call_as: CallAs = None,
1138
        **kwargs,
1139
    ) -> DescribeStackSetOutput:
1140
        state = get_cloudformation_store(context.account_id, context.region)
×
1141
        result = [
×
1142
            sset.metadata
1143
            for sset in state.stack_sets.values()
1144
            if sset.stack_set_name == stack_set_name
1145
        ]
1146
        if not result:
×
1147
            return not_found_error(f'Unable to find stack set "{stack_set_name}"')
×
1148

1149
        return DescribeStackSetOutput(StackSet=result[0])
×
1150

1151
    @handler("ListStackSets", expand=False)
1✔
1152
    def list_stack_sets(
1✔
1153
        self, context: RequestContext, request: ListStackSetsInput
1154
    ) -> ListStackSetsOutput:
1155
        state = get_cloudformation_store(context.account_id, context.region)
×
1156
        result = [sset.metadata for sset in state.stack_sets.values()]
×
1157
        return ListStackSetsOutput(Summaries=result)
×
1158

1159
    @handler("UpdateStackSet", expand=False)
1✔
1160
    def update_stack_set(
1✔
1161
        self, context: RequestContext, request: UpdateStackSetInput
1162
    ) -> UpdateStackSetOutput:
1163
        state = get_cloudformation_store(context.account_id, context.region)
×
1164
        set_name = request.get("StackSetName")
×
1165
        stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name]
×
1166
        if not stack_set:
×
1167
            return not_found_error(f'Stack set named "{set_name}" does not exist')
×
1168
        stack_set = stack_set[0]
×
1169
        stack_set.metadata.update(request)
×
1170
        op_id = request.get("OperationId") or short_uid()
×
1171
        operation = {
×
1172
            "OperationId": op_id,
1173
            "StackSetId": stack_set.metadata["StackSetId"],
1174
            "Action": "UPDATE",
1175
            "Status": "SUCCEEDED",
1176
        }
1177
        stack_set.operations[op_id] = operation
×
1178
        return UpdateStackSetOutput(OperationId=op_id)
×
1179

1180
    @handler("DeleteStackSet")
1✔
1181
    def delete_stack_set(
1✔
1182
        self,
1183
        context: RequestContext,
1184
        stack_set_name: StackSetName,
1185
        call_as: CallAs = None,
1186
        **kwargs,
1187
    ) -> DeleteStackSetOutput:
1188
        state = get_cloudformation_store(context.account_id, context.region)
×
1189
        stack_set = [
×
1190
            sset for sset in state.stack_sets.values() if sset.stack_set_name == stack_set_name
1191
        ]
1192

1193
        if not stack_set:
×
1194
            return not_found_error(f'Stack set named "{stack_set_name}" does not exist')
×
1195

1196
        # TODO: add a check for remaining stack instances
1197

1198
        for instance in stack_set[0].stack_instances:
×
1199
            deployer = template_deployer.TemplateDeployer(
×
1200
                context.account_id, context.region, instance.stack
1201
            )
1202
            deployer.delete_stack()
×
1203
        return DeleteStackSetOutput()
×
1204

1205
    @handler("CreateStackInstances", expand=False)
1✔
1206
    def create_stack_instances(
1✔
1207
        self,
1208
        context: RequestContext,
1209
        request: CreateStackInstancesInput,
1210
    ) -> CreateStackInstancesOutput:
1211
        state = get_cloudformation_store(context.account_id, context.region)
×
1212

1213
        set_name = request.get("StackSetName")
×
1214
        stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name]
×
1215

1216
        if not stack_set:
×
1217
            return not_found_error(f'Stack set named "{set_name}" does not exist')
×
1218

1219
        stack_set = stack_set[0]
×
1220
        op_id = request.get("OperationId") or short_uid()
×
1221
        sset_meta = stack_set.metadata
×
1222
        accounts = request["Accounts"]
×
1223
        regions = request["Regions"]
×
1224

1225
        stacks_to_await = []
×
1226
        for account in accounts:
×
1227
            for region in regions:
×
1228
                # deploy new stack
1229
                LOG.debug(
×
1230
                    'Deploying instance for stack set "%s" in account: %s region %s',
1231
                    set_name,
1232
                    account,
1233
                    region,
1234
                )
1235
                cf_client = connect_to(aws_access_key_id=account, region_name=region).cloudformation
×
1236
                kwargs = select_attributes(sset_meta, ["TemplateBody"]) or select_attributes(
×
1237
                    sset_meta, ["TemplateURL"]
1238
                )
1239
                stack_name = f"sset-{set_name}-{account}"
×
1240

1241
                # skip creation of existing stacks
1242
                if find_stack(context.account_id, context.region, stack_name):
×
1243
                    continue
×
1244

1245
                result = cf_client.create_stack(StackName=stack_name, **kwargs)
×
1246
                stacks_to_await.append((stack_name, account, region))
×
1247
                # store stack instance
1248
                instance = {
×
1249
                    "StackSetId": sset_meta["StackSetId"],
1250
                    "OperationId": op_id,
1251
                    "Account": account,
1252
                    "Region": region,
1253
                    "StackId": result["StackId"],
1254
                    "Status": "CURRENT",
1255
                    "StackInstanceStatus": {"DetailedStatus": "SUCCEEDED"},
1256
                }
1257
                instance = StackInstance(instance)
×
1258
                stack_set.stack_instances.append(instance)
×
1259

1260
        # wait for completion of stack
1261
        for stack_name, account_id, region_name in stacks_to_await:
×
1262
            client = connect_to(
×
1263
                aws_access_key_id=account_id, region_name=region_name
1264
            ).cloudformation
1265
            client.get_waiter("stack_create_complete").wait(StackName=stack_name)
×
1266

1267
        # record operation
1268
        operation = {
×
1269
            "OperationId": op_id,
1270
            "StackSetId": stack_set.metadata["StackSetId"],
1271
            "Action": "CREATE",
1272
            "Status": "SUCCEEDED",
1273
        }
1274
        stack_set.operations[op_id] = operation
×
1275

1276
        return CreateStackInstancesOutput(OperationId=op_id)
×
1277

1278
    @handler("ListStackInstances", expand=False)
1✔
1279
    def list_stack_instances(
1✔
1280
        self,
1281
        context: RequestContext,
1282
        request: ListStackInstancesInput,
1283
    ) -> ListStackInstancesOutput:
1284
        set_name = request.get("StackSetName")
×
1285
        state = get_cloudformation_store(context.account_id, context.region)
×
1286
        stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name]
×
1287
        if not stack_set:
×
1288
            return not_found_error(f'Stack set named "{set_name}" does not exist')
×
1289

1290
        stack_set = stack_set[0]
×
1291
        result = [inst.metadata for inst in stack_set.stack_instances]
×
1292
        return ListStackInstancesOutput(Summaries=result)
×
1293

1294
    @handler("DeleteStackInstances", expand=False)
1✔
1295
    def delete_stack_instances(
1✔
1296
        self,
1297
        context: RequestContext,
1298
        request: DeleteStackInstancesInput,
1299
    ) -> DeleteStackInstancesOutput:
1300
        op_id = request.get("OperationId") or short_uid()
×
1301

1302
        accounts = request["Accounts"]
×
1303
        regions = request["Regions"]
×
1304

1305
        state = get_cloudformation_store(context.account_id, context.region)
×
1306
        stack_sets = state.stack_sets.values()
×
1307

1308
        set_name = request.get("StackSetName")
×
1309
        stack_set = next((sset for sset in stack_sets if sset.stack_set_name == set_name), None)
×
1310

1311
        if not stack_set:
×
1312
            return not_found_error(f'Stack set named "{set_name}" does not exist')
×
1313

1314
        for account in accounts:
×
1315
            for region in regions:
×
1316
                instance = find_stack_instance(stack_set, account, region)
×
1317
                if instance:
×
1318
                    stack_set.stack_instances.remove(instance)
×
1319

1320
        # record operation
1321
        operation = {
×
1322
            "OperationId": op_id,
1323
            "StackSetId": stack_set.metadata["StackSetId"],
1324
            "Action": "DELETE",
1325
            "Status": "SUCCEEDED",
1326
        }
1327
        stack_set.operations[op_id] = operation
×
1328

1329
        return DeleteStackInstancesOutput(OperationId=op_id)
×
1330

1331
    @handler("RegisterType", expand=False)
1✔
1332
    def register_type(
1✔
1333
        self,
1334
        context: RequestContext,
1335
        request: RegisterTypeInput,
1336
    ) -> RegisterTypeOutput:
1337
        return RegisterTypeOutput()
×
1338

1339
    def list_types(
1✔
1340
        self, context: RequestContext, request: ListTypesInput, **kwargs
1341
    ) -> ListTypesOutput:
1342
        def is_list_overridden(child_class, parent_class):
×
1343
            if hasattr(child_class, "list"):
×
1344
                import inspect
×
1345

1346
                child_method = child_class.list
×
1347
                parent_method = parent_class.list
×
1348
                return inspect.unwrap(child_method) is not inspect.unwrap(parent_method)
×
1349
            return False
×
1350

1351
        def get_listable_types_summaries(plugin_manager):
×
1352
            plugins = plugin_manager.list_names()
×
1353
            type_summaries = []
×
1354
            for plugin in plugins:
×
1355
                type_summary = TypeSummary(
×
1356
                    Type=RegistryType.RESOURCE,
1357
                    TypeName=plugin,
1358
                )
1359
                provider = plugin_manager.load(plugin)
×
1360
                if is_list_overridden(provider.factory, ResourceProvider):
×
1361
                    type_summaries.append(type_summary)
×
1362
            return type_summaries
×
1363

1364
        from localstack.services.cloudformation.resource_provider import (
×
1365
            plugin_manager,
1366
        )
1367

1368
        type_summaries = get_listable_types_summaries(plugin_manager)
×
1369
        if PRO_RESOURCE_PROVIDERS:
×
1370
            from localstack.services.cloudformation.resource_provider import (
×
1371
                pro_plugin_manager,
1372
            )
1373

1374
            type_summaries.extend(get_listable_types_summaries(pro_plugin_manager))
×
1375

1376
        return ListTypesOutput(TypeSummaries=type_summaries)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc