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

localstack / localstack / 17265699519

27 Aug 2025 11:28AM UTC coverage: 86.827% (-0.01%) from 86.837%
17265699519

push

github

web-flow
Fix SQS tests failing due to missing snapshot update after #12957 (#13062)

67057 of 77231 relevant lines covered (86.83%)

0.87 hits per line

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

90.13
/localstack-core/localstack/services/cloudformation/v2/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 datetime import UTC, datetime
1✔
7

8
from localstack import config
1✔
9
from localstack.aws.api import RequestContext, handler
1✔
10
from localstack.aws.api.cloudformation import (
1✔
11
    AlreadyExistsException,
12
    CallAs,
13
    Changes,
14
    ChangeSetNameOrId,
15
    ChangeSetNotFoundException,
16
    ChangeSetStatus,
17
    ChangeSetType,
18
    ClientRequestToken,
19
    CreateChangeSetInput,
20
    CreateChangeSetOutput,
21
    CreateStackInput,
22
    CreateStackInstancesInput,
23
    CreateStackInstancesOutput,
24
    CreateStackOutput,
25
    CreateStackSetInput,
26
    CreateStackSetOutput,
27
    DeleteChangeSetOutput,
28
    DeleteStackInstancesInput,
29
    DeleteStackInstancesOutput,
30
    DeleteStackSetOutput,
31
    DeletionMode,
32
    DescribeChangeSetOutput,
33
    DescribeStackEventsOutput,
34
    DescribeStackResourceOutput,
35
    DescribeStackResourcesOutput,
36
    DescribeStackSetOperationOutput,
37
    DescribeStacksOutput,
38
    DisableRollback,
39
    EnableTerminationProtection,
40
    ExecuteChangeSetOutput,
41
    ExecutionStatus,
42
    GetTemplateOutput,
43
    GetTemplateSummaryInput,
44
    GetTemplateSummaryOutput,
45
    IncludePropertyValues,
46
    InsufficientCapabilitiesException,
47
    InvalidChangeSetStatusException,
48
    ListExportsOutput,
49
    ListStackResourcesOutput,
50
    ListStacksOutput,
51
    LogicalResourceId,
52
    NextToken,
53
    Parameter,
54
    PhysicalResourceId,
55
    ResourceStatus,
56
    RetainExceptOnCreate,
57
    RetainResources,
58
    RoleARN,
59
    RollbackConfiguration,
60
    StackDriftInformation,
61
    StackDriftStatus,
62
    StackName,
63
    StackNameOrId,
64
    StackResourceDetail,
65
    StackResourceSummary,
66
    StackSetName,
67
    StackSetNotFoundException,
68
    StackSetOperation,
69
    StackSetOperationAction,
70
    StackSetOperationStatus,
71
    StackStatus,
72
    StackStatusFilter,
73
    TemplateStage,
74
    UpdateStackInput,
75
    UpdateStackOutput,
76
    UpdateTerminationProtectionOutput,
77
)
78
from localstack.aws.api.cloudformation import (
1✔
79
    Stack as ApiStack,
80
)
81
from localstack.aws.connect import connect_to
1✔
82
from localstack.services.cloudformation import api_utils
1✔
83
from localstack.services.cloudformation.engine import template_preparer
1✔
84
from localstack.services.cloudformation.engine.parameters import resolve_ssm_parameter
1✔
85
from localstack.services.cloudformation.engine.transformers import FailedTransformationException
1✔
86
from localstack.services.cloudformation.engine.v2.change_set_model import (
1✔
87
    ChangeSetModel,
88
    ChangeType,
89
    UpdateModel,
90
)
91
from localstack.services.cloudformation.engine.v2.change_set_model_describer import (
1✔
92
    ChangeSetModelDescriber,
93
)
94
from localstack.services.cloudformation.engine.v2.change_set_model_executor import (
1✔
95
    ChangeSetModelExecutor,
96
)
97
from localstack.services.cloudformation.engine.v2.change_set_model_transform import (
1✔
98
    ChangeSetModelTransform,
99
)
100
from localstack.services.cloudformation.engine.v2.change_set_model_validator import (
1✔
101
    ChangeSetModelValidator,
102
)
103
from localstack.services.cloudformation.engine.validations import ValidationError
1✔
104
from localstack.services.cloudformation.provider import (
1✔
105
    ARN_CHANGESET_REGEX,
106
    ARN_STACK_REGEX,
107
    ARN_STACK_SET_REGEX,
108
    CloudformationProvider,
109
)
110
from localstack.services.cloudformation.stores import (
1✔
111
    CloudFormationStore,
112
    get_cloudformation_store,
113
)
114
from localstack.services.cloudformation.v2.entities import (
1✔
115
    ChangeSet,
116
    Stack,
117
    StackInstance,
118
    StackSet,
119
)
120
from localstack.services.cloudformation.v2.types import EngineParameter
1✔
121
from localstack.utils.collections import select_attributes
1✔
122
from localstack.utils.strings import short_uid
1✔
123
from localstack.utils.threads import start_worker_thread
1✔
124

125
LOG = logging.getLogger(__name__)
1✔
126

127
SSM_PARAMETER_TYPE_RE = re.compile(
1✔
128
    r"^AWS::SSM::Parameter::Value<(?P<listtype>List<)?(?P<innertype>[^>]+)>?>$"
129
)
130

131

132
def is_stack_arn(stack_name_or_id: str) -> bool:
1✔
133
    return ARN_STACK_REGEX.match(stack_name_or_id) is not None
1✔
134

135

136
def is_changeset_arn(change_set_name_or_id: str) -> bool:
1✔
137
    return ARN_CHANGESET_REGEX.match(change_set_name_or_id) is not None
1✔
138

139

140
def is_stack_set_arn(stack_set_name_or_id: str) -> bool:
1✔
141
    return ARN_STACK_SET_REGEX.match(stack_set_name_or_id) is not None
1✔
142

143

144
class StackNotFoundError(ValidationError):
1✔
145
    def __init__(self, stack_name_or_id: str, message_override: str | None = None):
1✔
146
        if message_override:
1✔
147
            super().__init__(message_override)
1✔
148
        else:
149
            if is_stack_arn(stack_name_or_id):
1✔
150
                super().__init__(f"Stack with id {stack_name_or_id} does not exist")
×
151
            else:
152
                super().__init__(f"Stack [{stack_name_or_id}] does not exist")
1✔
153

154

155
class StackSetNotFoundError(StackSetNotFoundException):
1✔
156
    def __init__(self, stack_set_name: str):
1✔
157
        super().__init__(f"StackSet {stack_set_name} not found")
1✔
158

159

160
def find_stack_v2(state: CloudFormationStore, stack_name: str | None) -> Stack | None:
1✔
161
    if stack_name:
1✔
162
        if is_stack_arn(stack_name):
1✔
163
            return state.stacks_v2[stack_name]
1✔
164
        else:
165
            stack_candidates = []
1✔
166
            for stack in state.stacks_v2.values():
1✔
167
                if stack.stack_name == stack_name and stack.status != StackStatus.DELETE_COMPLETE:
1✔
168
                    stack_candidates.append(stack)
1✔
169
            if len(stack_candidates) == 0:
1✔
170
                return None
1✔
171
            elif len(stack_candidates) > 1:
1✔
172
                raise RuntimeError("Programing error, duplicate stacks found")
×
173
            else:
174
                return stack_candidates[0]
1✔
175
    else:
176
        raise ValueError("No stack name specified when finding stack")
×
177

178

179
def find_change_set_v2(
1✔
180
    state: CloudFormationStore, change_set_name: str, stack_name: str | None = None
181
) -> ChangeSet | None:
182
    if is_changeset_arn(change_set_name):
1✔
183
        return state.change_sets.get(change_set_name)
1✔
184
    else:
185
        if stack_name is not None:
1✔
186
            stack = find_stack_v2(state, stack_name)
1✔
187
            if not stack:
1✔
188
                raise StackNotFoundError(stack_name)
1✔
189

190
            for change_set_id in stack.change_set_ids:
1✔
191
                change_set_candidate = state.change_sets[change_set_id]
1✔
192
                if change_set_candidate.change_set_name == change_set_name:
1✔
193
                    return change_set_candidate
1✔
194
        else:
195
            raise ValidationError(
1✔
196
                "StackName must be specified if ChangeSetName is not specified as an ARN."
197
            )
198

199

200
def find_stack_set_v2(state: CloudFormationStore, stack_set_name: str) -> StackSet | None:
1✔
201
    if is_stack_set_arn(stack_set_name):
1✔
202
        return state.stack_sets.get(stack_set_name)
×
203

204
    for stack_set in state.stack_sets_v2.values():
1✔
205
        if stack_set.stack_set_name == stack_set_name:
1✔
206
            return stack_set
1✔
207

208
    return None
1✔
209

210

211
def find_stack_instance(stack_set: StackSet, account: str, region: str) -> StackInstance | None:
1✔
212
    for instance in stack_set.stack_instances:
1✔
213
        if instance.account_id == account and instance.region_name == region:
1✔
214
            return instance
1✔
215
    return None
×
216

217

218
class CloudformationProviderV2(CloudformationProvider):
1✔
219
    @staticmethod
1✔
220
    def _resolve_parameters(
1✔
221
        template: dict | None, parameters: dict | None, account_id: str, region_name: str
222
    ) -> dict[str, EngineParameter]:
223
        template_parameters = template.get("Parameters", {})
1✔
224
        resolved_parameters = {}
1✔
225
        invalid_parameters = []
1✔
226
        for name, parameter in template_parameters.items():
1✔
227
            given_value = parameters.get(name)
1✔
228
            default_value = parameter.get("Default")
1✔
229
            resolved_parameter = EngineParameter(
1✔
230
                type_=parameter["Type"],
231
                given_value=given_value,
232
                default_value=default_value,
233
                no_echo=parameter.get("NoEcho"),
234
            )
235

236
            # TODO: support other parameter types
237
            if match := SSM_PARAMETER_TYPE_RE.match(parameter["Type"]):
1✔
238
                inner_type = match.group("innertype")
1✔
239
                is_list_type = match.group("listtype") is not None
1✔
240
                if is_list_type or inner_type == "CommaDelimitedList":
1✔
241
                    # list types
242
                    try:
1✔
243
                        resolved_value = resolve_ssm_parameter(
1✔
244
                            account_id, region_name, given_value or default_value
245
                        )
246
                        resolved_parameter["resolved_value"] = resolved_value.split(",")
1✔
247
                    except Exception:
×
248
                        raise ValidationError(
×
249
                            f"Parameter {name} should either have input value or default value"
250
                        )
251
                else:
252
                    try:
1✔
253
                        resolved_parameter["resolved_value"] = resolve_ssm_parameter(
1✔
254
                            account_id, region_name, given_value or default_value
255
                        )
256
                    except Exception:
1✔
257
                        raise ValidationError(
1✔
258
                            f"Parameter {name} should either have input value or default value"
259
                        )
260
            elif given_value is None and default_value is None:
1✔
261
                invalid_parameters.append(name)
1✔
262
                continue
1✔
263

264
            resolved_parameters[name] = resolved_parameter
1✔
265

266
        if invalid_parameters:
1✔
267
            raise ValidationError(f"Parameters: [{','.join(invalid_parameters)}] must have values")
1✔
268

269
        for name, parameter in resolved_parameters.items():
1✔
270
            if (
1✔
271
                parameter.get("resolved_value") is None
272
                and parameter.get("given_value") is None
273
                and parameter.get("default_value") is None
274
            ):
275
                raise ValidationError(
×
276
                    f"Parameter {name} should either have input value or default value"
277
                )
278

279
        return resolved_parameters
1✔
280

281
    @classmethod
1✔
282
    def _setup_change_set_model(
1✔
283
        cls,
284
        change_set: ChangeSet,
285
        before_template: dict | None,
286
        after_template: dict | None,
287
        before_parameters: dict | None,
288
        after_parameters: dict | None,
289
        previous_update_model: UpdateModel | None,
290
    ):
291
        resolved_parameters = None
1✔
292
        if after_parameters is not None:
1✔
293
            resolved_parameters = cls._resolve_parameters(
1✔
294
                after_template,
295
                after_parameters,
296
                change_set.stack.account_id,
297
                change_set.stack.region_name,
298
            )
299

300
        change_set.resolved_parameters = resolved_parameters
1✔
301

302
        # Create and preprocess the update graph for this template update.
303
        change_set_model = ChangeSetModel(
1✔
304
            before_template=before_template,
305
            after_template=after_template,
306
            before_parameters=before_parameters,
307
            after_parameters=resolved_parameters,
308
        )
309
        raw_update_model: UpdateModel = change_set_model.get_update_model()
1✔
310
        # If there exists an update model which operated in the 'before' version of this change set,
311
        # port the runtime values computed for the before version into this latest update model.
312
        if previous_update_model:
1✔
313
            raw_update_model.before_runtime_cache.clear()
1✔
314
            raw_update_model.before_runtime_cache.update(previous_update_model.after_runtime_cache)
1✔
315
        change_set.set_update_model(raw_update_model)
1✔
316

317
        # Apply global transforms.
318
        # TODO: skip this process iff both versions of the template don't specify transform blocks.
319
        change_set_model_transform = ChangeSetModelTransform(
1✔
320
            change_set=change_set,
321
            before_parameters=before_parameters,
322
            after_parameters=resolved_parameters,
323
            before_template=before_template,
324
            after_template=after_template,
325
        )
326
        try:
1✔
327
            transformed_before_template, transformed_after_template = (
1✔
328
                change_set_model_transform.transform()
329
            )
330
        except FailedTransformationException as e:
1✔
331
            change_set.status = ChangeSetStatus.FAILED
1✔
332
            change_set.status_reason = e.message
1✔
333
            change_set.stack.set_stack_status(
1✔
334
                status=StackStatus.ROLLBACK_IN_PROGRESS, reason=e.message
335
            )
336
            change_set.stack.set_stack_status(status=StackStatus.CREATE_FAILED)
1✔
337
            return
1✔
338

339
        # Remodel the update graph after the applying the global transforms.
340
        change_set_model = ChangeSetModel(
1✔
341
            before_template=transformed_before_template,
342
            after_template=transformed_after_template,
343
            before_parameters=before_parameters,
344
            after_parameters=resolved_parameters,
345
        )
346
        update_model = change_set_model.get_update_model()
1✔
347
        # Bring the cache for the previous operations forward in the update graph for this version
348
        # of the templates. This enables downstream update graph visitors to access runtime
349
        # information computed whilst evaluating the previous version of this template, and during
350
        # the transformations.
351
        update_model.before_runtime_cache.update(raw_update_model.before_runtime_cache)
1✔
352
        update_model.after_runtime_cache.update(raw_update_model.after_runtime_cache)
1✔
353
        change_set.set_update_model(update_model)
1✔
354

355
        # perform validations
356
        validator = ChangeSetModelValidator(
1✔
357
            change_set=change_set,
358
        )
359
        validator.validate()
1✔
360

361
        # hacky
362
        if transform := raw_update_model.node_template.transform:
1✔
363
            if transform.global_transforms:
1✔
364
                # global transforms should always be considered "MODIFIED"
365
                update_model.node_template.change_type = ChangeType.MODIFIED
1✔
366
        change_set.processed_template = transformed_after_template
1✔
367

368
    @handler("CreateChangeSet", expand=False)
1✔
369
    def create_change_set(
1✔
370
        self, context: RequestContext, request: CreateChangeSetInput
371
    ) -> CreateChangeSetOutput:
372
        stack_name = request.get("StackName")
1✔
373
        if not stack_name:
1✔
374
            # TODO: proper exception
375
            raise ValidationError("StackName must be specified")
1✔
376
        try:
1✔
377
            change_set_name = request["ChangeSetName"]
1✔
378
        except KeyError:
×
379
            # TODO: proper exception
380
            raise ValidationError("StackName must be specified")
×
381

382
        state = get_cloudformation_store(context.account_id, context.region)
1✔
383

384
        change_set_type = request.get("ChangeSetType", "UPDATE")
1✔
385
        template_body = request.get("TemplateBody")
1✔
386
        # s3 or secretsmanager url
387
        template_url = request.get("TemplateURL")
1✔
388

389
        # validate and resolve template
390
        if template_body and template_url:
1✔
391
            raise ValidationError(
×
392
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
393
            )  # TODO: check proper message
394

395
        if not template_body and not template_url:
1✔
396
            raise ValidationError(
×
397
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
398
            )  # TODO: check proper message
399

400
        template_body = api_utils.extract_template_body(request)
1✔
401
        structured_template = template_preparer.parse_template(template_body)
1✔
402

403
        if len(template_body) > 51200:
1✔
404
            raise ValidationError(
×
405
                f"1 validation error detected: Value '{template_body}' at 'templateBody' "
406
                "failed to satisfy constraint: Member must have length less than or equal to 51200"
407
            )
408

409
        # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing
410
        # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet)
411
        if is_stack_arn(stack_name):
1✔
412
            stack = state.stacks_v2.get(stack_name)
1✔
413
            if not stack:
1✔
414
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
415
        else:
416
            # stack name specified, so fetch the stack by name
417
            stack_candidates: list[Stack] = [
1✔
418
                s for stack_arn, s in state.stacks_v2.items() if s.stack_name == stack_name
419
            ]
420
            active_stack_candidates = [s for s in stack_candidates if s.is_active()]
1✔
421

422
            # on a CREATE an empty Stack should be generated if we didn't find an active one
423
            if not active_stack_candidates and change_set_type == ChangeSetType.CREATE:
1✔
424
                stack = Stack(
1✔
425
                    account_id=context.account_id,
426
                    region_name=context.region,
427
                    request_payload=request,
428
                    initial_status=StackStatus.REVIEW_IN_PROGRESS,
429
                )
430
                state.stacks_v2[stack.stack_id] = stack
1✔
431
            else:
432
                if not active_stack_candidates:
1✔
433
                    raise ValidationError(f"Stack '{stack_name}' does not exist.")
1✔
434
                stack = active_stack_candidates[0]
1✔
435

436
        # TODO: test if rollback status is allowed as well
437
        if (
1✔
438
            change_set_type == ChangeSetType.CREATE
439
            and stack.status != StackStatus.REVIEW_IN_PROGRESS
440
        ):
441
            raise ValidationError(
1✔
442
                f"Stack [{stack_name}] already exists and cannot be created again with the changeSet [{change_set_name}]."
443
            )
444

445
        before_parameters: dict[str, Parameter] | None = None
1✔
446
        match change_set_type:
1✔
447
            case ChangeSetType.UPDATE:
1✔
448
                before_parameters = stack.resolved_parameters
1✔
449
                # add changeset to existing stack
450
                # old_parameters = {
451
                #     k: mask_no_echo(strip_parameter_type(v))
452
                #     for k, v in stack.resolved_parameters.items()
453
                # }
454
            case ChangeSetType.IMPORT:
1✔
455
                raise NotImplementedError()  # TODO: implement importing resources
456
            case ChangeSetType.CREATE:
1✔
457
                pass
1✔
458
            case _:
×
459
                msg = (
×
460
                    f"1 validation error detected: Value '{change_set_type}' at 'changeSetType' failed to satisfy "
461
                    f"constraint: Member must satisfy enum value set: [IMPORT, UPDATE, CREATE] "
462
                )
463
                raise ValidationError(msg)
×
464

465
        # TODO: reconsider the way parameters are modelled in the update graph process.
466
        #  The options might be reduce to using the current style, or passing the extra information
467
        #  as a metadata object. The choice should be made considering when the extra information
468
        #  is needed for the update graph building, or only looked up in downstream tasks (metadata).
469
        request_parameters = request.get("Parameters", [])
1✔
470
        # TODO: handle parameter defaults and resolution
471
        after_parameters = self._extract_after_parameters(request_parameters, before_parameters)
1✔
472

473
        # TODO: update this logic to always pass the clean template object if one exists. The
474
        #  current issue with relaying on stack.template_original is that this appears to have
475
        #  its parameters and conditions populated.
476
        before_template = None
1✔
477
        if change_set_type == ChangeSetType.UPDATE:
1✔
478
            before_template = stack.template
1✔
479
        after_template = structured_template
1✔
480

481
        previous_update_model = None
1✔
482
        try:
1✔
483
            # FIXME: 'change_set_id' for 'stack' objects is dynamically attributed
484
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
485
                previous_update_model = previous_change_set.update_model
1✔
486
        except Exception:
1✔
487
            # No change set available on this stack.
488
            pass
1✔
489

490
        # create change set for the stack and apply changes
491
        change_set = ChangeSet(stack, request, template=after_template, template_body=template_body)
1✔
492
        self._setup_change_set_model(
1✔
493
            change_set=change_set,
494
            before_template=before_template,
495
            after_template=after_template,
496
            before_parameters=before_parameters,
497
            after_parameters=after_parameters,
498
            previous_update_model=previous_update_model,
499
        )
500

501
        # TODO: handle the empty change set case
502
        if not change_set.has_changes():
1✔
503
            change_set.set_change_set_status(ChangeSetStatus.FAILED)
1✔
504
            change_set.set_execution_status(ExecutionStatus.UNAVAILABLE)
1✔
505
            change_set.status_reason = "The submitted information didn't contain changes. Submit different information to create a change set."
1✔
506
        else:
507
            if stack.status not in [StackStatus.CREATE_COMPLETE, StackStatus.UPDATE_COMPLETE]:
1✔
508
                stack.set_stack_status(StackStatus.REVIEW_IN_PROGRESS)
1✔
509

510
            change_set.set_change_set_status(ChangeSetStatus.CREATE_COMPLETE)
1✔
511

512
        stack.change_set_ids.append(change_set.change_set_id)
1✔
513
        state.change_sets[change_set.change_set_id] = change_set
1✔
514

515
        return CreateChangeSetOutput(StackId=stack.stack_id, Id=change_set.change_set_id)
1✔
516

517
    @handler("ExecuteChangeSet")
1✔
518
    def execute_change_set(
1✔
519
        self,
520
        context: RequestContext,
521
        change_set_name: ChangeSetNameOrId,
522
        stack_name: StackNameOrId | None = None,
523
        client_request_token: ClientRequestToken | None = None,
524
        disable_rollback: DisableRollback | None = None,
525
        retain_except_on_create: RetainExceptOnCreate | None = None,
526
        **kwargs,
527
    ) -> ExecuteChangeSetOutput:
528
        state = get_cloudformation_store(context.account_id, context.region)
1✔
529

530
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
531
        if not change_set:
1✔
532
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
×
533

534
        if change_set.execution_status != ExecutionStatus.AVAILABLE:
1✔
535
            LOG.debug("Change set %s not in execution status 'AVAILABLE'", change_set_name)
1✔
536
            raise InvalidChangeSetStatusException(
1✔
537
                f"ChangeSet [{change_set.change_set_id}] cannot be executed in its current status of [{change_set.status}]"
538
            )
539
        # LOG.debug(
540
        #     'Executing change set "%s" for stack "%s" with %s resources ...',
541
        #     change_set_name,
542
        #     stack_name,
543
        #     len(change_set.template_resources),
544
        # )
545
        if not change_set.update_model:
1✔
546
            raise RuntimeError("Programming error: no update graph found for change set")
×
547

548
        change_set.set_execution_status(ExecutionStatus.EXECUTE_IN_PROGRESS)
1✔
549
        change_set.stack.set_stack_status(
1✔
550
            StackStatus.UPDATE_IN_PROGRESS
551
            if change_set.change_set_type == ChangeSetType.UPDATE
552
            else StackStatus.CREATE_IN_PROGRESS
553
        )
554

555
        change_set_executor = ChangeSetModelExecutor(
1✔
556
            change_set,
557
        )
558

559
        def _run(*args):
1✔
560
            result = change_set_executor.execute()
1✔
561
            change_set.stack.resolved_parameters = change_set.resolved_parameters
1✔
562
            change_set.stack.resolved_resources = result.resources
1✔
563
            if not result.failure_message:
1✔
564
                new_stack_status = StackStatus.UPDATE_COMPLETE
1✔
565
                if change_set.change_set_type == ChangeSetType.CREATE:
1✔
566
                    new_stack_status = StackStatus.CREATE_COMPLETE
1✔
567
                change_set.stack.set_stack_status(new_stack_status)
1✔
568
                change_set.set_execution_status(ExecutionStatus.EXECUTE_COMPLETE)
1✔
569
                change_set.stack.resolved_outputs = result.outputs
1✔
570

571
                change_set.stack.resolved_exports = {}
1✔
572
                for output in result.outputs:
1✔
573
                    if export_name := output.get("ExportName"):
1✔
574
                        change_set.stack.resolved_exports[export_name] = output["OutputValue"]
1✔
575

576
                change_set.stack.change_set_id = change_set.change_set_id
1✔
577
                change_set.stack.change_set_ids.append(change_set.change_set_id)
1✔
578

579
                # if the deployment succeeded, update the stack's template representation to that
580
                # which was just deployed
581
                change_set.stack.template = change_set.template
1✔
582
                change_set.stack.description = change_set.template.get("Description")
1✔
583
                change_set.stack.processed_template = change_set.processed_template
1✔
584
                change_set.stack.template_body = change_set.template_body
1✔
585
            else:
586
                LOG.error(
1✔
587
                    "Execute change set failed: %s",
588
                    result.failure_message,
589
                    exc_info=LOG.isEnabledFor(logging.DEBUG) and config.CFN_VERBOSE_ERRORS,
590
                )
591
                # stack status is taken care of in the executor
592
                change_set.set_execution_status(ExecutionStatus.EXECUTE_FAILED)
1✔
593
                change_set.stack.deletion_time = datetime.now(tz=UTC)
1✔
594

595
        start_worker_thread(_run)
1✔
596

597
        return ExecuteChangeSetOutput()
1✔
598

599
    @staticmethod
1✔
600
    def _render_resolved_parameters(
1✔
601
        resolved_parameters: dict[str, EngineParameter],
602
    ) -> list[Parameter]:
603
        result = []
1✔
604
        for name, resolved_parameter in resolved_parameters.items():
1✔
605
            parameter = Parameter(
1✔
606
                ParameterKey=name,
607
                ParameterValue=resolved_parameter.get("given_value")
608
                or resolved_parameter.get("default_value"),
609
            )
610
            if resolved_value := resolved_parameter.get("resolved_value"):
1✔
611
                parameter["ResolvedValue"] = resolved_value
1✔
612

613
            # TODO :what happens to the resolved value?
614
            if resolved_parameter.get("no_echo", False):
1✔
615
                parameter["ParameterValue"] = "****"
1✔
616
            result.append(parameter)
1✔
617

618
        return result
1✔
619

620
    @handler("DescribeChangeSet")
1✔
621
    def describe_change_set(
1✔
622
        self,
623
        context: RequestContext,
624
        change_set_name: ChangeSetNameOrId,
625
        stack_name: StackNameOrId | None = None,
626
        next_token: NextToken | None = None,
627
        include_property_values: IncludePropertyValues | None = None,
628
        **kwargs,
629
    ) -> DescribeChangeSetOutput:
630
        # TODO add support for include_property_values
631
        # only relevant if change_set_name isn't an ARN
632
        state = get_cloudformation_store(context.account_id, context.region)
1✔
633
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
634

635
        if not change_set:
1✔
636
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
1✔
637

638
        # TODO: The ChangeSetModelDescriber currently matches AWS behavior by listing
639
        #       resource changes in the order they appear in the template. However, when
640
        #       a resource change is triggered indirectly (e.g., via Ref or GetAtt), the
641
        #       dependency's change appears first in the list.
642
        #       Snapshot tests using the `capture_update_process` fixture rely on a
643
        #       normalizer to account for this ordering. This should be removed in the
644
        #       future by enforcing a consistently correct change ordering at the source.
645
        change_set_describer = ChangeSetModelDescriber(
1✔
646
            change_set=change_set, include_property_values=include_property_values
647
        )
648
        changes: Changes = change_set_describer.get_changes()
1✔
649

650
        result = DescribeChangeSetOutput(
1✔
651
            Status=change_set.status,
652
            ChangeSetId=change_set.change_set_id,
653
            ChangeSetName=change_set.change_set_name,
654
            ExecutionStatus=change_set.execution_status,
655
            RollbackConfiguration=RollbackConfiguration(),
656
            StackId=change_set.stack.stack_id,
657
            StackName=change_set.stack.stack_name,
658
            CreationTime=change_set.creation_time,
659
            Changes=changes,
660
            Capabilities=change_set.stack.capabilities,
661
            StatusReason=change_set.status_reason,
662
            Description=change_set.description,
663
            # TODO: static information
664
            IncludeNestedStacks=False,
665
            NotificationARNs=[],
666
        )
667
        if change_set.resolved_parameters:
1✔
668
            result["Parameters"] = self._render_resolved_parameters(change_set.resolved_parameters)
1✔
669
        return result
1✔
670

671
    @handler("DeleteChangeSet")
1✔
672
    def delete_change_set(
1✔
673
        self,
674
        context: RequestContext,
675
        change_set_name: ChangeSetNameOrId,
676
        stack_name: StackNameOrId = None,
677
        **kwargs,
678
    ) -> DeleteChangeSetOutput:
679
        state = get_cloudformation_store(context.account_id, context.region)
1✔
680
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
681
        if not change_set:
1✔
682
            return DeleteChangeSetOutput()
×
683

684
        change_set.stack.change_set_ids.remove(change_set.change_set_id)
1✔
685
        state.change_sets.pop(change_set.change_set_id)
1✔
686

687
        return DeleteChangeSetOutput()
1✔
688

689
    @handler("CreateStack", expand=False)
1✔
690
    def create_stack(self, context: RequestContext, request: CreateStackInput) -> CreateStackOutput:
1✔
691
        try:
1✔
692
            stack_name = request["StackName"]
1✔
693
        except KeyError:
×
694
            # TODO: proper exception
695
            raise ValidationError("StackName must be specified")
×
696

697
        state = get_cloudformation_store(context.account_id, context.region)
1✔
698

699
        active_stack_candidates = [
1✔
700
            stack
701
            for stack in state.stacks_v2.values()
702
            if stack.stack_name == stack_name and stack.status not in [StackStatus.DELETE_COMPLETE]
703
        ]
704

705
        # TODO: fix/implement this code path
706
        #   this needs more investigation how Cloudformation handles it (e.g. normal stack create or does it create a separate changeset?)
707
        # REVIEW_IN_PROGRESS is another special status
708
        # in this case existing changesets are set to obsolete and the stack is created
709
        # review_stack_candidates = [s for s in stack_candidates if s.status == StackStatus.REVIEW_IN_PROGRESS]
710
        # if review_stack_candidates:
711
        # set changesets to obsolete
712
        # for cs in review_stack_candidates[0].change_sets:
713
        #     cs.execution_status = ExecutionStatus.OBSOLETE
714

715
        if active_stack_candidates:
1✔
716
            raise AlreadyExistsException(f"Stack [{stack_name}] already exists")
1✔
717

718
        # TODO: copied from create_change_set, consider unifying
719
        template_body = request.get("TemplateBody")
1✔
720
        # s3 or secretsmanager url
721
        template_url = request.get("TemplateURL")
1✔
722

723
        # validate and resolve template
724
        if template_body and template_url:
1✔
725
            raise ValidationError(
×
726
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
727
            )  # TODO: check proper message
728

729
        if not template_body and not template_url:
1✔
730
            raise ValidationError(
×
731
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
732
            )  # TODO: check proper message
733

734
        template_body = api_utils.extract_template_body(request)
1✔
735
        structured_template = template_preparer.parse_template(template_body)
1✔
736

737
        if len(template_body) > 51200:
1✔
738
            raise ValidationError(
1✔
739
                f"1 validation error detected: Value '{template_body}' at 'templateBody' "
740
                "failed to satisfy constraint: Member must have length less than or equal to 51200"
741
            )
742

743
        if "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", []) and (
1✔
744
            "Transform" in structured_template.keys() or "Fn::Transform" in template_body
745
        ):
746
            raise InsufficientCapabilitiesException(
1✔
747
                "Requires capabilities : [CAPABILITY_AUTO_EXPAND]"
748
            )
749

750
        stack = Stack(
1✔
751
            account_id=context.account_id,
752
            region_name=context.region,
753
            request_payload=request,
754
        )
755
        # TODO: what is the correct initial status?
756
        state.stacks_v2[stack.stack_id] = stack
1✔
757

758
        # TODO: reconsider the way parameters are modelled in the update graph process.
759
        #  The options might be reduce to using the current style, or passing the extra information
760
        #  as a metadata object. The choice should be made considering when the extra information
761
        #  is needed for the update graph building, or only looked up in downstream tasks (metadata).
762
        request_parameters = request.get("Parameters", [])
1✔
763
        # TODO: handle parameter defaults and resolution
764
        after_parameters = self._extract_after_parameters(request_parameters)
1✔
765
        after_template = structured_template
1✔
766

767
        # Create internal change set to execute
768
        change_set = ChangeSet(
1✔
769
            stack,
770
            {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE},
771
            template=after_template,
772
            template_body=template_body,
773
        )
774
        self._setup_change_set_model(
1✔
775
            change_set=change_set,
776
            before_template=None,
777
            after_template=after_template,
778
            before_parameters=None,
779
            after_parameters=after_parameters,
780
            previous_update_model=None,
781
        )
782
        if change_set.status == ChangeSetStatus.FAILED:
1✔
783
            return CreateStackOutput(StackId=stack.stack_id)
1✔
784

785
        stack.processed_template = change_set.processed_template
1✔
786

787
        # deployment process
788
        stack.set_stack_status(StackStatus.CREATE_IN_PROGRESS)
1✔
789
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
790

791
        def _run(*args):
1✔
792
            try:
1✔
793
                result = change_set_executor.execute()
1✔
794
                stack.resolved_resources = result.resources
1✔
795
                stack.resolved_outputs = result.outputs
1✔
796
                if all(
1✔
797
                    resource["ResourceStatus"] == ResourceStatus.CREATE_COMPLETE
798
                    for resource in stack.resolved_resources.values()
799
                ):
800
                    stack.set_stack_status(StackStatus.CREATE_COMPLETE)
1✔
801
                else:
802
                    stack.set_stack_status(StackStatus.CREATE_FAILED)
1✔
803

804
                # if the deployment succeeded, update the stack's template representation to that
805
                # which was just deployed
806
                stack.template = change_set.template
1✔
807
                stack.template_body = change_set.template_body
1✔
808
                stack.processed_template = change_set.processed_template
1✔
809
                stack.resolved_parameters = change_set.resolved_parameters
1✔
810
                stack.resolved_exports = {}
1✔
811
                for output in result.outputs:
1✔
812
                    if export_name := output.get("ExportName"):
1✔
813
                        stack.resolved_exports[export_name] = output["OutputValue"]
1✔
814
            except Exception as e:
×
815
                LOG.error(
×
816
                    "Create Stack set failed: %s",
817
                    e,
818
                    exc_info=LOG.isEnabledFor(logging.WARNING) and config.CFN_VERBOSE_ERRORS,
819
                )
820
                stack.set_stack_status(StackStatus.CREATE_FAILED)
×
821

822
        start_worker_thread(_run)
1✔
823

824
        return CreateStackOutput(StackId=stack.stack_id)
1✔
825

826
    @handler("CreateStackSet", expand=False)
1✔
827
    def create_stack_set(
1✔
828
        self, context: RequestContext, request: CreateStackSetInput
829
    ) -> CreateStackSetOutput:
830
        state = get_cloudformation_store(context.account_id, context.region)
1✔
831
        stack_set = StackSet(context.account_id, context.region, request)
1✔
832
        state.stack_sets_v2[stack_set.stack_set_id] = stack_set
1✔
833

834
        return CreateStackSetOutput(StackSetId=stack_set.stack_set_id)
1✔
835

836
    @handler("DescribeStacks")
1✔
837
    def describe_stacks(
1✔
838
        self,
839
        context: RequestContext,
840
        stack_name: StackName = None,
841
        next_token: NextToken = None,
842
        **kwargs,
843
    ) -> DescribeStacksOutput:
844
        state = get_cloudformation_store(context.account_id, context.region)
1✔
845
        if stack_name:
1✔
846
            stack = find_stack_v2(state, stack_name)
1✔
847
            if not stack:
1✔
848
                raise ValidationError(f"Stack with id {stack_name} does not exist")
1✔
849
            stacks = [stack]
1✔
850
        else:
851
            stacks = state.stacks_v2.values()
1✔
852

853
        describe_stack_output: list[ApiStack] = []
1✔
854
        for stack in stacks:
1✔
855
            describe_stack_output.append(self._describe_stack(stack))
1✔
856

857
        return DescribeStacksOutput(Stacks=describe_stack_output)
1✔
858

859
    def _describe_stack(self, stack: Stack) -> ApiStack:
1✔
860
        stack_description = ApiStack(
1✔
861
            Description=stack.description,
862
            CreationTime=stack.creation_time,
863
            StackId=stack.stack_id,
864
            StackName=stack.stack_name,
865
            StackStatus=stack.status,
866
            StackStatusReason=stack.status_reason,
867
            # fake values
868
            DisableRollback=False,
869
            DriftInformation=StackDriftInformation(StackDriftStatus=StackDriftStatus.NOT_CHECKED),
870
            EnableTerminationProtection=stack.enable_termination_protection,
871
            RollbackConfiguration=RollbackConfiguration(),
872
            Tags=[],
873
            NotificationARNs=[],
874
        )
875
        if stack.status != StackStatus.REVIEW_IN_PROGRESS:
1✔
876
            # TODO: actually track updated time
877
            stack_description["LastUpdatedTime"] = stack.creation_time
1✔
878
        if stack.deletion_time:
1✔
879
            stack_description["DeletionTime"] = stack.deletion_time
1✔
880
        if stack.capabilities:
1✔
881
            stack_description["Capabilities"] = stack.capabilities
1✔
882
        # TODO: confirm the logic for this
883
        if change_set_id := stack.change_set_id:
1✔
884
            stack_description["ChangeSetId"] = change_set_id
1✔
885

886
        if stack.resolved_parameters:
1✔
887
            stack_description["Parameters"] = self._render_resolved_parameters(
1✔
888
                stack.resolved_parameters
889
            )
890

891
        if stack.resolved_outputs:
1✔
892
            stack_description["Outputs"] = stack.resolved_outputs
1✔
893

894
        return stack_description
1✔
895

896
    @handler("ListStacks")
1✔
897
    def list_stacks(
1✔
898
        self,
899
        context: RequestContext,
900
        next_token: NextToken = None,
901
        stack_status_filter: StackStatusFilter = None,
902
        **kwargs,
903
    ) -> ListStacksOutput:
904
        state = get_cloudformation_store(context.account_id, context.region)
1✔
905

906
        stacks = [
1✔
907
            self._describe_stack(s)
908
            for s in state.stacks_v2.values()
909
            if not stack_status_filter or s.status in stack_status_filter
910
        ]
911

912
        attrs = [
1✔
913
            "StackId",
914
            "StackName",
915
            "TemplateDescription",
916
            "CreationTime",
917
            "LastUpdatedTime",
918
            "DeletionTime",
919
            "StackStatus",
920
            "StackStatusReason",
921
            "ParentId",
922
            "RootId",
923
            "DriftInformation",
924
        ]
925
        stacks = [select_attributes(stack, attrs) for stack in stacks]
1✔
926
        return ListStacksOutput(StackSummaries=stacks)
1✔
927

928
    @handler("ListStackResources")
1✔
929
    def list_stack_resources(
1✔
930
        self, context: RequestContext, stack_name: StackName, next_token: NextToken = None, **kwargs
931
    ) -> ListStackResourcesOutput:
932
        result = self.describe_stack_resources(context, stack_name)
1✔
933

934
        resources = []
1✔
935
        for resource in result.get("StackResources", []):
1✔
936
            resources.append(
1✔
937
                StackResourceSummary(
938
                    LogicalResourceId=resource["LogicalResourceId"],
939
                    PhysicalResourceId=resource["PhysicalResourceId"],
940
                    ResourceType=resource["ResourceType"],
941
                    LastUpdatedTimestamp=resource["Timestamp"],
942
                    ResourceStatus=resource["ResourceStatus"],
943
                    ResourceStatusReason=resource.get("ResourceStatusReason"),
944
                    DriftInformation=resource.get("DriftInformation"),
945
                    ModuleInfo=resource.get("ModuleInfo"),
946
                )
947
            )
948

949
        return ListStackResourcesOutput(StackResourceSummaries=resources)
1✔
950

951
    @handler("DescribeStackResource")
1✔
952
    def describe_stack_resource(
1✔
953
        self,
954
        context: RequestContext,
955
        stack_name: StackName,
956
        logical_resource_id: LogicalResourceId,
957
        **kwargs,
958
    ) -> DescribeStackResourceOutput:
959
        state = get_cloudformation_store(context.account_id, context.region)
1✔
960
        stack = find_stack_v2(state, stack_name)
1✔
961
        if not stack:
1✔
962
            raise StackNotFoundError(
1✔
963
                stack_name, message_override=f"Stack '{stack_name}' does not exist"
964
            )
965

966
        try:
1✔
967
            resource = stack.resolved_resources[logical_resource_id]
1✔
968
        except KeyError:
1✔
969
            raise ValidationError(
1✔
970
                f"Resource {logical_resource_id} does not exist for stack {stack_name}"
971
            )
972

973
        resource_detail = StackResourceDetail(
1✔
974
            StackName=stack.stack_name,
975
            StackId=stack.stack_id,
976
            LogicalResourceId=logical_resource_id,
977
            PhysicalResourceId=resource["PhysicalResourceId"],
978
            ResourceType=resource["Type"],
979
            LastUpdatedTimestamp=resource["LastUpdatedTimestamp"],
980
            ResourceStatus=resource["ResourceStatus"],
981
        )
982
        return DescribeStackResourceOutput(StackResourceDetail=resource_detail)
1✔
983

984
    @handler("DescribeStackResources")
1✔
985
    def describe_stack_resources(
1✔
986
        self,
987
        context: RequestContext,
988
        stack_name: StackName = None,
989
        logical_resource_id: LogicalResourceId = None,
990
        physical_resource_id: PhysicalResourceId = None,
991
        **kwargs,
992
    ) -> DescribeStackResourcesOutput:
993
        if physical_resource_id and stack_name:
1✔
994
            raise ValidationError("Cannot specify both StackName and PhysicalResourceId")
×
995
        state = get_cloudformation_store(context.account_id, context.region)
1✔
996
        stack = find_stack_v2(state, stack_name)
1✔
997
        if not stack:
1✔
998
            raise StackNotFoundError(stack_name)
×
999
        # TODO: filter stack by PhysicalResourceId!
1000
        statuses = []
1✔
1001
        for resource_id, resource_status in stack.resource_states.items():
1✔
1002
            if resource_id == logical_resource_id or logical_resource_id is None:
1✔
1003
                status = copy.deepcopy(resource_status)
1✔
1004
                status.setdefault("DriftInformation", {"StackResourceDriftStatus": "NOT_CHECKED"})
1✔
1005
                statuses.append(status)
1✔
1006
        return DescribeStackResourcesOutput(StackResources=statuses)
1✔
1007

1008
    @handler("CreateStackInstances", expand=False)
1✔
1009
    def create_stack_instances(
1✔
1010
        self,
1011
        context: RequestContext,
1012
        request: CreateStackInstancesInput,
1013
    ) -> CreateStackInstancesOutput:
1014
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1015

1016
        stack_set_name = request["StackSetName"]
1✔
1017
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1018
        if not stack_set:
1✔
1019
            raise StackSetNotFoundError(stack_set_name)
1✔
1020

1021
        op_id = request.get("OperationId") or short_uid()
1✔
1022
        accounts = request["Accounts"]
1✔
1023
        regions = request["Regions"]
1✔
1024

1025
        stacks_to_await = []
1✔
1026
        for account in accounts:
1✔
1027
            for region in regions:
1✔
1028
                # deploy new stack
1029
                LOG.debug(
1✔
1030
                    'Deploying instance for stack set "%s" in account: %s region %s',
1031
                    stack_set_name,
1032
                    account,
1033
                    region,
1034
                )
1035
                cf_client = connect_to(aws_access_key_id=account, region_name=region).cloudformation
1✔
1036
                if stack_set.template_body:
1✔
1037
                    kwargs = {
1✔
1038
                        "TemplateBody": stack_set.template_body,
1039
                    }
1040
                elif stack_set.template_url:
×
1041
                    kwargs = {
×
1042
                        "TemplateURL": stack_set.template_url,
1043
                    }
1044
                else:
1045
                    # TODO: wording
1046
                    raise ValueError("Neither StackSet Template URL nor TemplateBody provided")
×
1047
                stack_name = f"sset-{stack_set_name}-{account}-{region}"
1✔
1048

1049
                # skip creation of existing stacks
1050
                if find_stack_v2(state, stack_name):
1✔
1051
                    continue
1✔
1052

1053
                result = cf_client.create_stack(StackName=stack_name, **kwargs)
1✔
1054
                # store stack instance
1055
                stack_instance = StackInstance(
1✔
1056
                    account_id=account,
1057
                    region_name=region,
1058
                    stack_set_id=stack_set.stack_set_id,
1059
                    operation_id=op_id,
1060
                    stack_id=result["StackId"],
1061
                )
1062
                stack_set.stack_instances.append(stack_instance)
1✔
1063

1064
                stacks_to_await.append((stack_name, account, region))
1✔
1065

1066
        # wait for completion of stack
1067
        for stack_name, account_id, region_name in stacks_to_await:
1✔
1068
            client = connect_to(
1✔
1069
                aws_access_key_id=account_id, region_name=region_name
1070
            ).cloudformation
1071
            client.get_waiter("stack_create_complete").wait(StackName=stack_name)
1✔
1072

1073
        # record operation
1074
        operation = StackSetOperation(
1✔
1075
            OperationId=op_id,
1076
            StackSetId=stack_set.stack_set_id,
1077
            Action=StackSetOperationAction.CREATE,
1078
            Status=StackSetOperationStatus.SUCCEEDED,
1079
        )
1080
        stack_set.operations[op_id] = operation
1✔
1081

1082
        return CreateStackInstancesOutput(OperationId=op_id)
1✔
1083

1084
    @handler("DescribeStackSetOperation")
1✔
1085
    def describe_stack_set_operation(
1✔
1086
        self,
1087
        context: RequestContext,
1088
        stack_set_name: StackSetName,
1089
        operation_id: ClientRequestToken,
1090
        call_as: CallAs = None,
1091
        **kwargs,
1092
    ) -> DescribeStackSetOperationOutput:
1093
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1094
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1095
        if not stack_set:
1✔
1096
            raise StackSetNotFoundError(stack_set_name)
×
1097

1098
        result = stack_set.operations.get(operation_id)
1✔
1099
        if not result:
1✔
1100
            LOG.debug(
×
1101
                'Unable to find operation ID "%s" for stack set "%s" in list: %s',
1102
                operation_id,
1103
                stack_set_name,
1104
                list(stack_set.operations.keys()),
1105
            )
1106
            # TODO: proper exception
1107
            raise ValueError(
×
1108
                f'Unable to find operation ID "{operation_id}" for stack set "{stack_set_name}"'
1109
            )
1110

1111
        return DescribeStackSetOperationOutput(StackSetOperation=result)
1✔
1112

1113
    @handler("DeleteStackInstances", expand=False)
1✔
1114
    def delete_stack_instances(
1✔
1115
        self,
1116
        context: RequestContext,
1117
        request: DeleteStackInstancesInput,
1118
    ) -> DeleteStackInstancesOutput:
1119
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1120

1121
        stack_set_name = request["StackSetName"]
1✔
1122
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1123
        if not stack_set:
1✔
1124
            raise StackSetNotFoundError(stack_set_name)
×
1125

1126
        op_id = request.get("OperationId") or short_uid()
1✔
1127

1128
        accounts = request["Accounts"]
1✔
1129
        regions = request["Regions"]
1✔
1130

1131
        operations_to_await = []
1✔
1132
        for account in accounts:
1✔
1133
            for region in regions:
1✔
1134
                cf_client = connect_to(aws_access_key_id=account, region_name=region).cloudformation
1✔
1135
                instance = find_stack_instance(stack_set, account, region)
1✔
1136

1137
                # TODO: check parity with AWS
1138
                # TODO: delete stack instance?
1139
                if not instance:
1✔
1140
                    continue
×
1141

1142
                cf_client.delete_stack(StackName=instance.stack_id)
1✔
1143
                operations_to_await.append(instance)
1✔
1144

1145
        for instance in operations_to_await:
1✔
1146
            cf_client = connect_to(
1✔
1147
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1148
            ).cloudformation
1149
            cf_client.get_waiter("stack_delete_complete").wait(StackName=instance.stack_id)
1✔
1150
            stack_set.stack_instances.remove(instance)
1✔
1151

1152
        # record operation
1153
        operation = StackSetOperation(
1✔
1154
            OperationId=op_id,
1155
            StackSetId=stack_set.stack_set_id,
1156
            Action=StackSetOperationAction.DELETE,
1157
            Status=StackSetOperationStatus.SUCCEEDED,
1158
        )
1159
        stack_set.operations[op_id] = operation
1✔
1160

1161
        return DeleteStackInstancesOutput(OperationId=op_id)
1✔
1162

1163
    @handler("DeleteStackSet")
1✔
1164
    def delete_stack_set(
1✔
1165
        self,
1166
        context: RequestContext,
1167
        stack_set_name: StackSetName,
1168
        call_as: CallAs = None,
1169
        **kwargs,
1170
    ) -> DeleteStackSetOutput:
1171
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1172
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1173
        if not stack_set:
1✔
1174
            # operation is idempotent
1175
            return DeleteStackSetOutput()
1✔
1176

1177
        # clean up any left-over instances
1178
        operations_to_await = []
1✔
1179
        for instance in stack_set.stack_instances:
1✔
1180
            cf_client = connect_to(
×
1181
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1182
            ).cloudformation
1183
            cf_client.delete_stack(StackName=instance.stack_id)
×
1184
            operations_to_await.append(instance)
×
1185

1186
        for instance in operations_to_await:
1✔
1187
            cf_client = connect_to(
×
1188
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1189
            ).cloudformation
1190
            cf_client.get_waiter("stack_delete_complete").wait(StackName=instance.stack_id)
×
1191
            stack_set.stack_instances.remove(instance)
×
1192

1193
        state.stack_sets_v2.pop(stack_set.stack_set_id)
1✔
1194

1195
        return DeleteStackSetOutput()
1✔
1196

1197
    @handler("DescribeStackEvents")
1✔
1198
    def describe_stack_events(
1✔
1199
        self,
1200
        context: RequestContext,
1201
        stack_name: StackName = None,
1202
        next_token: NextToken = None,
1203
        **kwargs,
1204
    ) -> DescribeStackEventsOutput:
1205
        if not stack_name:
1✔
1206
            raise ValidationError(
1✔
1207
                "1 validation error detected: Value null at 'stackName' failed to satisfy constraint: Member must not be null"
1208
            )
1209
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1210
        stack = find_stack_v2(state, stack_name)
1✔
1211
        if not stack:
1✔
1212
            raise StackNotFoundError(stack_name)
1✔
1213
        return DescribeStackEventsOutput(StackEvents=stack.events)
1✔
1214

1215
    @handler("GetTemplate")
1✔
1216
    def get_template(
1✔
1217
        self,
1218
        context: RequestContext,
1219
        stack_name: StackName = None,
1220
        change_set_name: ChangeSetNameOrId = None,
1221
        template_stage: TemplateStage = None,
1222
        **kwargs,
1223
    ) -> GetTemplateOutput:
1224
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1225
        if change_set_name:
1✔
1226
            change_set = find_change_set_v2(state, change_set_name, stack_name=stack_name)
×
1227
            stack = change_set.stack
×
1228
        elif stack_name:
1✔
1229
            stack = find_stack_v2(state, stack_name)
1✔
1230
        else:
1231
            raise StackNotFoundError(stack_name)
×
1232

1233
        if template_stage == TemplateStage.Processed and "Transform" in stack.template_body:
1✔
1234
            template_body = json.dumps(stack.processed_template)
1✔
1235
        else:
1236
            template_body = stack.template_body
1✔
1237

1238
        return GetTemplateOutput(
1✔
1239
            TemplateBody=template_body,
1240
            StagesAvailable=[TemplateStage.Original, TemplateStage.Processed],
1241
        )
1242

1243
    @handler("GetTemplateSummary", expand=False)
1✔
1244
    def get_template_summary(
1✔
1245
        self,
1246
        context: RequestContext,
1247
        request: GetTemplateSummaryInput,
1248
    ) -> GetTemplateSummaryOutput:
1249
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1250
        stack_name = request.get("StackName")
1✔
1251

1252
        if stack_name:
1✔
1253
            stack = find_stack_v2(state, stack_name)
1✔
1254
            if not stack:
1✔
1255
                raise StackNotFoundError(stack_name)
×
1256
            template = stack.template
1✔
1257
        else:
1258
            template_body = request.get("TemplateBody")
1✔
1259
            # s3 or secretsmanager url
1260
            template_url = request.get("TemplateURL")
1✔
1261

1262
            # validate and resolve template
1263
            if template_body and template_url:
1✔
1264
                raise ValidationError(
×
1265
                    "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1266
                )  # TODO: check proper message
1267

1268
            if not template_body and not template_url:
1✔
1269
                raise ValidationError(
×
1270
                    "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1271
                )  # TODO: check proper message
1272

1273
            template_body = api_utils.extract_template_body(request)
1✔
1274
            template = template_preparer.parse_template(template_body)
1✔
1275

1276
        id_summaries = defaultdict(list)
1✔
1277
        for resource_id, resource in template["Resources"].items():
1✔
1278
            res_type = resource["Type"]
1✔
1279
            id_summaries[res_type].append(resource_id)
1✔
1280

1281
        summarized_parameters = []
1✔
1282
        for parameter_id, parameter_body in template.get("Parameters", {}).items():
1✔
1283
            summarized_parameters.append(
1✔
1284
                {
1285
                    "ParameterKey": parameter_id,
1286
                    "DefaultValue": parameter_body.get("Default"),
1287
                    "ParameterType": parameter_body["Type"],
1288
                    "Description": parameter_body.get("Description"),
1289
                }
1290
            )
1291
        result = GetTemplateSummaryOutput(
1✔
1292
            Parameters=summarized_parameters,
1293
            Metadata=template.get("Metadata"),
1294
            ResourceIdentifierSummaries=[
1295
                {"ResourceType": key, "LogicalResourceIds": values}
1296
                for key, values in id_summaries.items()
1297
            ],
1298
            ResourceTypes=list(id_summaries.keys()),
1299
            Version=template.get("AWSTemplateFormatVersion", "2010-09-09"),
1300
        )
1301

1302
        return result
1✔
1303

1304
    @handler("UpdateTerminationProtection")
1✔
1305
    def update_termination_protection(
1✔
1306
        self,
1307
        context: RequestContext,
1308
        enable_termination_protection: EnableTerminationProtection,
1309
        stack_name: StackNameOrId,
1310
        **kwargs,
1311
    ) -> UpdateTerminationProtectionOutput:
1312
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1313
        stack = find_stack_v2(state, stack_name)
1✔
1314
        if not stack:
1✔
1315
            raise StackNotFoundError(stack_name)
×
1316

1317
        stack.enable_termination_protection = enable_termination_protection
1✔
1318
        return UpdateTerminationProtectionOutput(StackId=stack.stack_id)
1✔
1319

1320
    @handler("UpdateStack", expand=False)
1✔
1321
    def update_stack(
1✔
1322
        self,
1323
        context: RequestContext,
1324
        request: UpdateStackInput,
1325
    ) -> UpdateStackOutput:
1326
        try:
1✔
1327
            stack_name = request["StackName"]
1✔
1328
        except KeyError:
×
1329
            # TODO: proper exception
1330
            raise ValidationError("StackName must be specified")
×
1331
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1332
        template_body = request.get("TemplateBody")
1✔
1333
        # s3 or secretsmanager url
1334
        template_url = request.get("TemplateURL")
1✔
1335

1336
        # validate and resolve template
1337
        if template_body and template_url:
1✔
1338
            raise ValidationError(
×
1339
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1340
            )  # TODO: check proper message
1341

1342
        if not template_body and not template_url:
1✔
1343
            raise ValidationError(
×
1344
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1345
            )  # TODO: check proper message
1346

1347
        template_body = api_utils.extract_template_body(request)
1✔
1348
        structured_template = template_preparer.parse_template(template_body)
1✔
1349

1350
        if "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", []) and (
1✔
1351
            "Transform" in structured_template.keys() or "Fn::Transform" in template_body
1352
        ):
1353
            raise InsufficientCapabilitiesException(
×
1354
                "Requires capabilities : [CAPABILITY_AUTO_EXPAND]"
1355
            )
1356

1357
        # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing
1358
        # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet)
1359
        stack: Stack
1360
        if is_stack_arn(stack_name):
1✔
1361
            stack = state.stacks_v2.get(stack_name)
×
1362
            if not stack:
×
1363
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
1364

1365
        else:
1366
            # stack name specified, so fetch the stack by name
1367
            stack_candidates: list[Stack] = [
1✔
1368
                s for stack_arn, s in state.stacks_v2.items() if s.stack_name == stack_name
1369
            ]
1370
            active_stack_candidates = [
1✔
1371
                s for s in stack_candidates if self._stack_status_is_active(s.status)
1372
            ]
1373

1374
            if not active_stack_candidates:
1✔
1375
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
1376
            elif len(active_stack_candidates) > 1:
1✔
1377
                raise RuntimeError("Multiple stacks matched, update matching logic")
×
1378
            stack = active_stack_candidates[0]
1✔
1379

1380
        # TODO: proper status modeling
1381
        before_parameters = stack.resolved_parameters
1✔
1382
        # TODO: reconsider the way parameters are modelled in the update graph process.
1383
        #  The options might be reduce to using the current style, or passing the extra information
1384
        #  as a metadata object. The choice should be made considering when the extra information
1385
        #  is needed for the update graph building, or only looked up in downstream tasks (metadata).
1386
        request_parameters = request.get("Parameters", [])
1✔
1387
        # TODO: handle parameter defaults and resolution
1388
        after_parameters = self._extract_after_parameters(request_parameters, before_parameters)
1✔
1389

1390
        before_template = stack.template
1✔
1391
        after_template = structured_template
1✔
1392

1393
        previous_update_model = None
1✔
1394
        if stack.change_set_id:
1✔
1395
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
1396
                previous_update_model = previous_change_set.update_model
1✔
1397

1398
        change_set = ChangeSet(
1✔
1399
            stack,
1400
            {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE},
1401
            template_body=template_body,
1402
            template=after_template,
1403
        )
1404
        self._setup_change_set_model(
1✔
1405
            change_set=change_set,
1406
            before_template=before_template,
1407
            after_template=after_template,
1408
            before_parameters=before_parameters,
1409
            after_parameters=after_parameters,
1410
            previous_update_model=previous_update_model,
1411
        )
1412

1413
        # TODO: some changes are only detectable at runtime; consider using
1414
        #       the ChangeSetModelDescriber, or a new custom visitors, to
1415
        #       pick-up on runtime changes.
1416
        if not change_set.has_changes():
1✔
1417
            raise ValidationError("No updates are to be performed.")
1✔
1418

1419
        stack.set_stack_status(StackStatus.UPDATE_IN_PROGRESS)
1✔
1420
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
1421

1422
        def _run(*args):
1✔
1423
            try:
1✔
1424
                result = change_set_executor.execute()
1✔
1425
                stack.set_stack_status(StackStatus.UPDATE_COMPLETE)
1✔
1426
                stack.resolved_resources = result.resources
1✔
1427
                stack.resolved_outputs = result.outputs
1✔
1428
                # if the deployment succeeded, update the stack's template representation to that
1429
                # which was just deployed
1430
                stack.template = change_set.template
1✔
1431
                stack.template_body = change_set.template_body
1✔
1432
                stack.resolved_parameters = change_set.resolved_parameters
1✔
1433
                stack.resolved_exports = {}
1✔
1434
                for output in result.outputs:
1✔
1435
                    if export_name := output.get("ExportName"):
1✔
1436
                        stack.resolved_exports[export_name] = output["OutputValue"]
1✔
1437
            except Exception as e:
×
1438
                LOG.error(
×
1439
                    "Update Stack failed: %s",
1440
                    e,
1441
                    exc_info=LOG.isEnabledFor(logging.WARNING) and config.CFN_VERBOSE_ERRORS,
1442
                )
1443
                stack.set_stack_status(StackStatus.UPDATE_FAILED)
×
1444

1445
        start_worker_thread(_run)
1✔
1446

1447
        return UpdateStackOutput(StackId=stack.stack_id)
1✔
1448

1449
    @staticmethod
1✔
1450
    def _extract_after_parameters(
1✔
1451
        request_parameters, before_parameters: dict[str, str] | None = None
1452
    ) -> dict[str, str]:
1453
        before_parameters = before_parameters or {}
1✔
1454
        after_parameters = {}
1✔
1455
        for parameter in request_parameters:
1✔
1456
            key = parameter["ParameterKey"]
1✔
1457
            if parameter.get("UsePreviousValue", False):
1✔
1458
                # todo: what if the parameter does not exist in the before parameters
1459
                before = before_parameters[key]
1✔
1460
                after_parameters[key] = (
1✔
1461
                    before.get("resolved_value")
1462
                    or before.get("given_value")
1463
                    or before.get("default_value")
1464
                )
1465
                continue
1✔
1466

1467
            if "ParameterValue" in parameter:
1✔
1468
                after_parameters[key] = parameter["ParameterValue"]
1✔
1469
                continue
1✔
1470
        return after_parameters
1✔
1471

1472
    @handler("DeleteStack")
1✔
1473
    def delete_stack(
1✔
1474
        self,
1475
        context: RequestContext,
1476
        stack_name: StackName,
1477
        retain_resources: RetainResources = None,
1478
        role_arn: RoleARN = None,
1479
        client_request_token: ClientRequestToken = None,
1480
        deletion_mode: DeletionMode = None,
1481
        **kwargs,
1482
    ) -> None:
1483
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1484
        stack = find_stack_v2(state, stack_name)
1✔
1485
        if not stack:
1✔
1486
            # aws will silently ignore invalid stack names - we should do the same
1487
            return
1✔
1488

1489
        # shortcut for stacks which have no deployed resources i.e. where a change set was
1490
        # created, but never executed
1491
        if stack.status == StackStatus.REVIEW_IN_PROGRESS and not stack.resolved_resources:
1✔
1492
            stack.set_stack_status(StackStatus.DELETE_COMPLETE)
1✔
1493
            stack.deletion_time = datetime.now(tz=UTC)
1✔
1494
            return
1✔
1495

1496
        stack.set_stack_status(StackStatus.DELETE_IN_PROGRESS)
1✔
1497

1498
        previous_update_model = None
1✔
1499
        if stack.change_set_id:
1✔
1500
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
1501
                previous_update_model = previous_change_set.update_model
1✔
1502

1503
        # create a dummy change set
1504
        change_set = ChangeSet(
1✔
1505
            stack, {"ChangeSetName": f"delete-stack_{stack.stack_name}"}, template_body=""
1506
        )  # noqa
1507
        self._setup_change_set_model(
1✔
1508
            change_set=change_set,
1509
            before_template=stack.processed_template,
1510
            after_template=None,
1511
            before_parameters=stack.resolved_parameters,
1512
            after_parameters=None,
1513
            previous_update_model=previous_update_model,
1514
        )
1515

1516
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
1517

1518
        def _run(*args):
1✔
1519
            try:
1✔
1520
                change_set_executor.execute()
1✔
1521
                stack.set_stack_status(StackStatus.DELETE_COMPLETE)
1✔
1522
                stack.deletion_time = datetime.now(tz=UTC)
1✔
1523
            except Exception as e:
×
1524
                LOG.warning(
×
1525
                    "Failed to delete stack '%s': %s",
1526
                    stack.stack_name,
1527
                    e,
1528
                    exc_info=LOG.isEnabledFor(logging.DEBUG) and config.CFN_VERBOSE_ERRORS,
1529
                )
1530
                stack.set_stack_status(StackStatus.DELETE_FAILED)
×
1531

1532
        start_worker_thread(_run)
1✔
1533

1534
    @handler("ListExports")
1✔
1535
    def list_exports(
1✔
1536
        self, context: RequestContext, next_token: NextToken = None, **kwargs
1537
    ) -> ListExportsOutput:
1538
        store = get_cloudformation_store(account_id=context.account_id, region_name=context.region)
1✔
1539
        return ListExportsOutput(Exports=store.exports_v2.values())
1✔
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