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

localstack / localstack / 19451970107

17 Nov 2025 08:17PM UTC coverage: 86.905% (+0.003%) from 86.902%
19451970107

push

github

web-flow
Update CODEOWNERS (#13383)

Co-authored-by: LocalStack Bot <localstack-bot@users.noreply.github.com>

68622 of 78962 relevant lines covered (86.91%)

0.87 hits per line

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

90.01
/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
from urllib.parse import urlencode
1✔
8

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

130
LOG = logging.getLogger(__name__)
1✔
131

132
SSM_PARAMETER_TYPE_RE = re.compile(
1✔
133
    r"^AWS::SSM::Parameter::Value<(?P<listtype>List<)?(?P<innertype>[^>]+)>?>$"
134
)
135

136

137
def is_stack_arn(stack_name_or_id: str) -> bool:
1✔
138
    return stack_name_or_id and ARN_STACK_REGEX.match(stack_name_or_id) is not None
1✔
139

140

141
def is_changeset_arn(change_set_name_or_id: str) -> bool:
1✔
142
    return change_set_name_or_id and ARN_CHANGESET_REGEX.match(change_set_name_or_id) is not None
1✔
143

144

145
def is_stack_set_arn(stack_set_name_or_id: str) -> bool:
1✔
146
    return stack_set_name_or_id and ARN_STACK_SET_REGEX.match(stack_set_name_or_id) is not None
1✔
147

148

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

159

160
class StackSetNotFoundError(StackSetNotFoundException):
1✔
161
    def __init__(self, stack_set_name: str):
1✔
162
        super().__init__(f"StackSet {stack_set_name} not found")
1✔
163

164

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

183

184
def find_change_set_v2(
1✔
185
    state: CloudFormationStore, change_set_name: str, stack_name: str | None = None
186
) -> ChangeSet | None:
187
    if is_changeset_arn(change_set_name):
1✔
188
        return state.change_sets.get(change_set_name)
1✔
189
    else:
190
        if stack_name is not None:
1✔
191
            stack = find_stack_v2(state, stack_name)
1✔
192
            if not stack:
1✔
193
                raise StackNotFoundError(stack_name)
1✔
194

195
            for change_set_id in stack.change_set_ids:
1✔
196
                change_set_candidate = state.change_sets[change_set_id]
1✔
197
                if change_set_candidate.change_set_name == change_set_name:
1✔
198
                    return change_set_candidate
1✔
199
        else:
200
            raise ValidationError(
1✔
201
                "StackName must be specified if ChangeSetName is not specified as an ARN."
202
            )
203

204

205
def find_stack_set_v2(state: CloudFormationStore, stack_set_name: str) -> StackSet | None:
1✔
206
    if is_stack_set_arn(stack_set_name):
1✔
207
        return state.stack_sets.get(stack_set_name)
×
208

209
    for stack_set in state.stack_sets_v2.values():
1✔
210
        if stack_set.stack_set_name == stack_set_name:
1✔
211
            return stack_set
1✔
212

213
    return None
1✔
214

215

216
def find_stack_instance(stack_set: StackSet, account: str, region: str) -> StackInstance | None:
1✔
217
    for instance in stack_set.stack_instances:
1✔
218
        if instance.account_id == account and instance.region_name == region:
1✔
219
            return instance
1✔
220
    return None
×
221

222

223
class CloudformationProviderV2(CloudformationProvider, ServiceLifecycleHook):
1✔
224
    def on_before_start(self):
1✔
225
        base = "https://github.com/localstack/localstack/issues/new"
1✔
226
        query_args = {
1✔
227
            "template": "bug-report.yml",
228
            "labels": ",".join(
229
                [
230
                    "aws:cloudformation:v2",
231
                    "status: triage needed",
232
                    "type: bug",
233
                ]
234
            ),
235
            "title": "CFNV2: ",
236
        }
237
        issue_url = "?".join([base, urlencode(query_args)])
1✔
238
        LOG.info(
1✔
239
            "You have opted in to the new CloudFormation deployment engine. "
240
            "You can opt in to using the old engine by setting PROVIDER_OVERRIDE_CLOUDFORMATION=engine-legacy. "
241
            "If you experience issues, please submit a bug report at this URL: %s",
242
            issue_url,
243
        )
244

245
    @staticmethod
1✔
246
    def _resolve_parameters(
1✔
247
        template: dict | None,
248
        parameters: dict | None,
249
        account_id: str,
250
        region_name: str,
251
        before_parameters: dict | None,
252
    ) -> dict[str, EngineParameter]:
253
        template_parameters = template.get("Parameters", {})
1✔
254
        resolved_parameters = {}
1✔
255
        invalid_parameters = []
1✔
256
        for name, parameter in template_parameters.items():
1✔
257
            given_value = parameters.get(name)
1✔
258
            default_value = parameter.get("Default")
1✔
259
            resolved_parameter = EngineParameter(
1✔
260
                type_=parameter["Type"],
261
                given_value=given_value,
262
                default_value=default_value,
263
                no_echo=parameter.get("NoEcho"),
264
            )
265

266
            # validate the type
267
            if parameter["Type"] == "Number" and not is_number(
1✔
268
                engine_parameter_value(resolved_parameter)
269
            ):
270
                raise ValidationError(f"Parameter '{name}' must be a number.")
1✔
271

272
            # TODO: support other parameter types
273
            if match := SSM_PARAMETER_TYPE_RE.match(parameter["Type"]):
1✔
274
                inner_type = match.group("innertype")
1✔
275
                is_list_type = match.group("listtype") is not None
1✔
276
                if is_list_type or inner_type == "CommaDelimitedList":
1✔
277
                    # list types
278
                    try:
1✔
279
                        resolved_value = resolve_ssm_parameter(
1✔
280
                            account_id, region_name, given_value or default_value
281
                        )
282
                        resolved_parameter["resolved_value"] = resolved_value.split(",")
1✔
283
                    except Exception:
×
284
                        raise ValidationError(
×
285
                            f"Parameter {name} should either have input value or default value"
286
                        )
287
                else:
288
                    try:
1✔
289
                        resolved_parameter["resolved_value"] = resolve_ssm_parameter(
1✔
290
                            account_id, region_name, given_value or default_value
291
                        )
292
                    except Exception as e:
1✔
293
                        # we could not find the parameter however CDK provides the resolved value rather than the
294
                        # parameter name again so try to look up the value in the previous parameters
295
                        if (
1✔
296
                            before_parameters
297
                            and (before_param := before_parameters.get(name))
298
                            and isinstance(before_param, dict)
299
                            and (resolved_value := before_param.get("resolved_value"))
300
                        ):
301
                            LOG.debug(
1✔
302
                                "Parameter %s could not be resolved, using previous value of %s",
303
                                name,
304
                                resolved_value,
305
                            )
306
                            resolved_parameter["resolved_value"] = resolved_value
1✔
307
                        else:
308
                            raise ValidationError(
1✔
309
                                f"Parameter {name} should either have input value or default value"
310
                            ) from e
311
            elif given_value is None and default_value is None:
1✔
312
                invalid_parameters.append(name)
1✔
313
                continue
1✔
314

315
            resolved_parameters[name] = resolved_parameter
1✔
316

317
        if invalid_parameters:
1✔
318
            raise ValidationError(f"Parameters: [{','.join(invalid_parameters)}] must have values")
1✔
319

320
        for name, parameter in resolved_parameters.items():
1✔
321
            if (
1✔
322
                parameter.get("resolved_value") is None
323
                and parameter.get("given_value") is None
324
                and parameter.get("default_value") is None
325
            ):
326
                raise ValidationError(
×
327
                    f"Parameter {name} should either have input value or default value"
328
                )
329

330
        return resolved_parameters
1✔
331

332
    @classmethod
1✔
333
    def _setup_change_set_model(
1✔
334
        cls,
335
        change_set: ChangeSet,
336
        before_template: dict | None,
337
        after_template: dict | None,
338
        before_parameters: dict | None,
339
        after_parameters: dict | None,
340
        previous_update_model: UpdateModel | None = None,
341
    ):
342
        resolved_parameters = None
1✔
343
        if after_parameters is not None:
1✔
344
            resolved_parameters = cls._resolve_parameters(
1✔
345
                after_template,
346
                after_parameters,
347
                change_set.stack.account_id,
348
                change_set.stack.region_name,
349
                before_parameters,
350
            )
351

352
        change_set.resolved_parameters = resolved_parameters
1✔
353

354
        # Create and preprocess the update graph for this template update.
355
        change_set_model = ChangeSetModel(
1✔
356
            before_template=before_template,
357
            after_template=after_template,
358
            before_parameters=before_parameters,
359
            after_parameters=resolved_parameters,
360
        )
361
        raw_update_model: UpdateModel = change_set_model.get_update_model()
1✔
362
        # If there exists an update model which operated in the 'before' version of this change set,
363
        # port the runtime values computed for the before version into this latest update model.
364
        if previous_update_model:
1✔
365
            raw_update_model.before_runtime_cache.clear()
1✔
366
            raw_update_model.before_runtime_cache.update(previous_update_model.after_runtime_cache)
1✔
367
        change_set.set_update_model(raw_update_model)
1✔
368

369
        # Apply global transforms.
370
        # TODO: skip this process iff both versions of the template don't specify transform blocks.
371
        change_set_model_transform = ChangeSetModelTransform(
1✔
372
            change_set=change_set,
373
            before_parameters=before_parameters,
374
            after_parameters=resolved_parameters,
375
            before_template=before_template,
376
            after_template=after_template,
377
        )
378
        try:
1✔
379
            transformed_before_template, transformed_after_template = (
1✔
380
                change_set_model_transform.transform()
381
            )
382
        except FailedTransformationException as e:
1✔
383
            change_set.status = ChangeSetStatus.FAILED
1✔
384
            change_set.status_reason = e.message
1✔
385
            change_set.stack.set_stack_status(
1✔
386
                status=StackStatus.ROLLBACK_IN_PROGRESS, reason=e.message
387
            )
388
            change_set.stack.set_stack_status(status=StackStatus.CREATE_FAILED)
1✔
389
            return
1✔
390

391
        # Remodel the update graph after the applying the global transforms.
392
        change_set_model = ChangeSetModel(
1✔
393
            before_template=transformed_before_template,
394
            after_template=transformed_after_template,
395
            before_parameters=before_parameters,
396
            after_parameters=resolved_parameters,
397
        )
398
        update_model = change_set_model.get_update_model()
1✔
399
        # Bring the cache for the previous operations forward in the update graph for this version
400
        # of the templates. This enables downstream update graph visitors to access runtime
401
        # information computed whilst evaluating the previous version of this template, and during
402
        # the transformations.
403
        update_model.before_runtime_cache.update(raw_update_model.before_runtime_cache)
1✔
404
        update_model.after_runtime_cache.update(raw_update_model.after_runtime_cache)
1✔
405
        change_set.set_update_model(update_model)
1✔
406

407
        # perform validations
408
        validator = ChangeSetModelValidator(
1✔
409
            change_set=change_set,
410
        )
411
        validator.validate()
1✔
412

413
        # hacky
414
        if transform := raw_update_model.node_template.transform:
1✔
415
            if transform.global_transforms:
1✔
416
                # global transforms should always be considered "MODIFIED"
417
                update_model.node_template.change_type = ChangeType.MODIFIED
1✔
418
        change_set.processed_template = transformed_after_template
1✔
419

420
    @handler("CreateChangeSet", expand=False)
1✔
421
    def create_change_set(
1✔
422
        self, context: RequestContext, request: CreateChangeSetInput
423
    ) -> CreateChangeSetOutput:
424
        stack_name = request.get("StackName")
1✔
425
        if not stack_name:
1✔
426
            # TODO: proper exception
427
            raise ValidationError("StackName must be specified")
1✔
428
        try:
1✔
429
            change_set_name = request["ChangeSetName"]
1✔
430
        except KeyError:
×
431
            # TODO: proper exception
432
            raise ValidationError("StackName must be specified")
×
433

434
        state = get_cloudformation_store(context.account_id, context.region)
1✔
435

436
        change_set_type = request.get("ChangeSetType", "UPDATE")
1✔
437
        template_body = request.get("TemplateBody")
1✔
438
        # s3 or secretsmanager url
439
        template_url = request.get("TemplateURL")
1✔
440

441
        # validate and resolve template
442
        if template_body and template_url:
1✔
443
            raise ValidationError(
×
444
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
445
            )  # TODO: check proper message
446

447
        if not template_body and not template_url:
1✔
448
            raise ValidationError(
×
449
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
450
            )  # TODO: check proper message
451

452
        template_body = api_utils.extract_template_body(request)
1✔
453
        structured_template = template_preparer.parse_template(template_body)
1✔
454

455
        if len(template_body) > 51200 and not template_url:
1✔
456
            raise ValidationError(
×
457
                f"1 validation error detected: Value '{template_body}' at 'templateBody' "
458
                "failed to satisfy constraint: Member must have length less than or equal to 51200"
459
            )
460

461
        # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing
462
        # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet)
463
        if is_stack_arn(stack_name):
1✔
464
            stack = state.stacks_v2.get(stack_name)
1✔
465
            if not stack:
1✔
466
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
467
            stack.capabilities = request.get("Capabilities") or []
1✔
468
        else:
469
            # stack name specified, so fetch the stack by name
470
            stack_candidates: list[Stack] = [
1✔
471
                s for stack_arn, s in state.stacks_v2.items() if s.stack_name == stack_name
472
            ]
473
            active_stack_candidates = [s for s in stack_candidates if s.is_active()]
1✔
474

475
            # on a CREATE an empty Stack should be generated if we didn't find an active one
476
            if not active_stack_candidates and change_set_type == ChangeSetType.CREATE:
1✔
477
                stack = Stack(
1✔
478
                    account_id=context.account_id,
479
                    region_name=context.region,
480
                    request_payload=request,
481
                    initial_status=StackStatus.REVIEW_IN_PROGRESS,
482
                )
483
                state.stacks_v2[stack.stack_id] = stack
1✔
484
            else:
485
                if not active_stack_candidates:
1✔
486
                    raise ValidationError(f"Stack '{stack_name}' does not exist.")
1✔
487
                stack = active_stack_candidates[0]
1✔
488
                # propagate capabilities from create change set request
489
                stack.capabilities = request.get("Capabilities") or []
1✔
490

491
        # TODO: test if rollback status is allowed as well
492
        if (
1✔
493
            change_set_type == ChangeSetType.CREATE
494
            and stack.status != StackStatus.REVIEW_IN_PROGRESS
495
        ):
496
            raise ValidationError(
1✔
497
                f"Stack [{stack_name}] already exists and cannot be created again with the changeSet [{change_set_name}]."
498
            )
499

500
        if change_set_type == ChangeSetType.UPDATE and (
1✔
501
            stack.status == StackStatus.DELETE_COMPLETE
502
            or stack.status == StackStatus.DELETE_IN_PROGRESS
503
        ):
504
            raise ValidationError(
×
505
                f"Stack:{stack.stack_id} is in {stack.status} state and can not be updated."
506
            )
507

508
        before_parameters: dict[str, Parameter] | None = None
1✔
509
        match change_set_type:
1✔
510
            case ChangeSetType.UPDATE:
1✔
511
                before_parameters = stack.resolved_parameters
1✔
512
                # add changeset to existing stack
513
                # old_parameters = {
514
                #     k: mask_no_echo(strip_parameter_type(v))
515
                #     for k, v in stack.resolved_parameters.items()
516
                # }
517
            case ChangeSetType.IMPORT:
1✔
518
                raise NotImplementedError()  # TODO: implement importing resources
519
            case ChangeSetType.CREATE:
1✔
520
                pass
1✔
521
            case _:
×
522
                msg = (
×
523
                    f"1 validation error detected: Value '{change_set_type}' at 'changeSetType' failed to satisfy "
524
                    f"constraint: Member must satisfy enum value set: [IMPORT, UPDATE, CREATE] "
525
                )
526
                raise ValidationError(msg)
×
527

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

536
        # TODO: update this logic to always pass the clean template object if one exists. The
537
        #  current issue with relaying on stack.template_original is that this appears to have
538
        #  its parameters and conditions populated.
539
        before_template = None
1✔
540
        if change_set_type == ChangeSetType.UPDATE:
1✔
541
            before_template = stack.template
1✔
542
        after_template = structured_template
1✔
543

544
        previous_update_model = None
1✔
545
        try:
1✔
546
            # FIXME: 'change_set_id' for 'stack' objects is dynamically attributed
547
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
548
                previous_update_model = previous_change_set.update_model
1✔
549
        except Exception:
1✔
550
            # No change set available on this stack.
551
            pass
1✔
552

553
        # create change set for the stack and apply changes
554
        change_set = ChangeSet(
1✔
555
            stack,
556
            request,
557
            template=after_template,
558
            template_body=template_body,
559
        )
560
        self._setup_change_set_model(
1✔
561
            change_set=change_set,
562
            before_template=before_template,
563
            after_template=after_template,
564
            before_parameters=before_parameters,
565
            after_parameters=after_parameters,
566
            previous_update_model=previous_update_model,
567
        )
568
        if change_set.status == ChangeSetStatus.FAILED:
1✔
569
            change_set.set_execution_status(ExecutionStatus.UNAVAILABLE)
1✔
570
        else:
571
            if not change_set.has_changes():
1✔
572
                change_set.set_change_set_status(ChangeSetStatus.FAILED)
1✔
573
                change_set.set_execution_status(ExecutionStatus.UNAVAILABLE)
1✔
574
                change_set.status_reason = "The submitted information didn't contain changes. Submit different information to create a change set."
1✔
575
            else:
576
                if stack.status not in [StackStatus.CREATE_COMPLETE, StackStatus.UPDATE_COMPLETE]:
1✔
577
                    stack.set_stack_status(StackStatus.REVIEW_IN_PROGRESS, "User Initiated")
1✔
578

579
                change_set.set_change_set_status(ChangeSetStatus.CREATE_COMPLETE)
1✔
580

581
        stack.change_set_ids.add(change_set.change_set_id)
1✔
582
        state.change_sets[change_set.change_set_id] = change_set
1✔
583
        return CreateChangeSetOutput(StackId=stack.stack_id, Id=change_set.change_set_id)
1✔
584

585
    @handler("ExecuteChangeSet")
1✔
586
    def execute_change_set(
1✔
587
        self,
588
        context: RequestContext,
589
        change_set_name: ChangeSetNameOrId,
590
        stack_name: StackNameOrId | None = None,
591
        client_request_token: ClientRequestToken | None = None,
592
        disable_rollback: DisableRollback | None = None,
593
        retain_except_on_create: RetainExceptOnCreate | None = None,
594
        **kwargs,
595
    ) -> ExecuteChangeSetOutput:
596
        state = get_cloudformation_store(context.account_id, context.region)
1✔
597

598
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
599
        if not change_set:
1✔
600
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
×
601

602
        if change_set.execution_status != ExecutionStatus.AVAILABLE:
1✔
603
            LOG.debug("Change set %s not in execution status 'AVAILABLE'", change_set_name)
1✔
604
            raise InvalidChangeSetStatusException(
1✔
605
                f"ChangeSet [{change_set.change_set_id}] cannot be executed in its current status of [{change_set.status}]"
606
            )
607
        # LOG.debug(
608
        #     'Executing change set "%s" for stack "%s" with %s resources ...',
609
        #     change_set_name,
610
        #     stack_name,
611
        #     len(change_set.template_resources),
612
        # )
613
        if not change_set.update_model:
1✔
614
            raise RuntimeError("Programming error: no update graph found for change set")
×
615

616
        change_set.set_execution_status(ExecutionStatus.EXECUTE_IN_PROGRESS)
1✔
617
        # propagate the tags as this is done during execution
618
        change_set.stack.tags = change_set.tags
1✔
619
        change_set.stack.set_stack_status(
1✔
620
            StackStatus.UPDATE_IN_PROGRESS
621
            if change_set.change_set_type == ChangeSetType.UPDATE
622
            else StackStatus.CREATE_IN_PROGRESS
623
        )
624

625
        change_set_executor = ChangeSetModelExecutor(
1✔
626
            change_set,
627
        )
628

629
        def _run(*args):
1✔
630
            # TODO: should this be cleared before or after execution?
631
            change_set.stack.status_reason = None
1✔
632
            result = change_set_executor.execute()
1✔
633
            change_set.stack.resolved_parameters = change_set.resolved_parameters
1✔
634
            change_set.stack.resolved_resources = result.resources
1✔
635
            change_set.stack.template = change_set.template
1✔
636
            change_set.stack.processed_template = change_set.processed_template
1✔
637
            change_set.stack.template_body = change_set.template_body
1✔
638
            change_set.stack.description = change_set.template.get("Description")
1✔
639

640
            if not result.failure_message:
1✔
641
                new_stack_status = StackStatus.UPDATE_COMPLETE
1✔
642
                if change_set.change_set_type == ChangeSetType.CREATE:
1✔
643
                    new_stack_status = StackStatus.CREATE_COMPLETE
1✔
644
                change_set.stack.set_stack_status(new_stack_status)
1✔
645
                change_set.set_execution_status(ExecutionStatus.EXECUTE_COMPLETE)
1✔
646
                change_set.stack.resolved_outputs = result.outputs
1✔
647

648
                change_set.stack.resolved_exports = {}
1✔
649
                for output in result.outputs:
1✔
650
                    if export_name := output.get("ExportName"):
1✔
651
                        change_set.stack.resolved_exports[export_name] = output["OutputValue"]
1✔
652

653
                change_set.stack.change_set_id = change_set.change_set_id
1✔
654
            else:
655
                LOG.error(
1✔
656
                    "Execute change set failed: %s",
657
                    result.failure_message,
658
                    exc_info=LOG.isEnabledFor(logging.DEBUG) and config.CFN_VERBOSE_ERRORS,
659
                )
660
                # stack status is taken care of in the executor
661
                change_set.set_execution_status(ExecutionStatus.EXECUTE_FAILED)
1✔
662
                change_set.stack.deletion_time = datetime.now(tz=UTC)
1✔
663

664
        start_worker_thread(_run)
1✔
665

666
        return ExecuteChangeSetOutput()
1✔
667

668
    @staticmethod
1✔
669
    def _render_resolved_parameters(
1✔
670
        resolved_parameters: dict[str, EngineParameter],
671
    ) -> list[Parameter]:
672
        result = []
1✔
673
        for name, resolved_parameter in resolved_parameters.items():
1✔
674
            parameter = Parameter(
1✔
675
                ParameterKey=name,
676
                ParameterValue=resolved_parameter.get("given_value")
677
                or resolved_parameter.get("default_value"),
678
            )
679
            if resolved_value := resolved_parameter.get("resolved_value"):
1✔
680
                parameter["ResolvedValue"] = resolved_value
1✔
681

682
            # TODO :what happens to the resolved value?
683
            if resolved_parameter.get("no_echo", False):
1✔
684
                parameter["ParameterValue"] = "****"
1✔
685
            result.append(parameter)
1✔
686

687
        return result
1✔
688

689
    @handler("DescribeChangeSet")
1✔
690
    def describe_change_set(
1✔
691
        self,
692
        context: RequestContext,
693
        change_set_name: ChangeSetNameOrId,
694
        stack_name: StackNameOrId | None = None,
695
        next_token: NextToken | None = None,
696
        include_property_values: IncludePropertyValues | None = None,
697
        **kwargs,
698
    ) -> DescribeChangeSetOutput:
699
        # TODO add support for include_property_values
700
        # only relevant if change_set_name isn't an ARN
701
        state = get_cloudformation_store(context.account_id, context.region)
1✔
702
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
703

704
        if not change_set:
1✔
705
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
1✔
706

707
        # if the change set failed to create, then we can return a blank response
708
        if change_set.status == ChangeSetStatus.FAILED:
1✔
709
            return DescribeChangeSetOutput(
1✔
710
                Status=change_set.status,
711
                ChangeSetId=change_set.change_set_id,
712
                ChangeSetName=change_set.change_set_name,
713
                ExecutionStatus=change_set.execution_status,
714
                RollbackConfiguration=RollbackConfiguration(),
715
                StackId=change_set.stack.stack_id,
716
                StackName=change_set.stack.stack_name,
717
                CreationTime=change_set.creation_time,
718
                Changes=[],
719
                Capabilities=change_set.stack.capabilities,
720
                StatusReason=change_set.status_reason,
721
                Description=change_set.description,
722
                # TODO: static information
723
                IncludeNestedStacks=False,
724
                NotificationARNs=[],
725
                Tags=change_set.tags or None,
726
            )
727

728
        # TODO: The ChangeSetModelDescriber currently matches AWS behavior by listing
729
        #       resource changes in the order they appear in the template. However, when
730
        #       a resource change is triggered indirectly (e.g., via Ref or GetAtt), the
731
        #       dependency's change appears first in the list.
732
        #       Snapshot tests using the `capture_update_process` fixture rely on a
733
        #       normalizer to account for this ordering. This should be removed in the
734
        #       future by enforcing a consistently correct change ordering at the source.
735
        change_set_describer = ChangeSetModelDescriber(
1✔
736
            change_set=change_set, include_property_values=include_property_values
737
        )
738
        changes: Changes = change_set_describer.get_changes()
1✔
739

740
        result = DescribeChangeSetOutput(
1✔
741
            Status=change_set.status,
742
            ChangeSetId=change_set.change_set_id,
743
            ChangeSetName=change_set.change_set_name,
744
            ExecutionStatus=change_set.execution_status,
745
            RollbackConfiguration=RollbackConfiguration(),
746
            StackId=change_set.stack.stack_id,
747
            StackName=change_set.stack.stack_name,
748
            CreationTime=change_set.creation_time,
749
            Changes=changes,
750
            Capabilities=change_set.stack.capabilities,
751
            StatusReason=change_set.status_reason,
752
            Description=change_set.description,
753
            # TODO: static information
754
            IncludeNestedStacks=False,
755
            NotificationARNs=[],
756
            Tags=change_set.tags or None,
757
        )
758
        if change_set.resolved_parameters:
1✔
759
            result["Parameters"] = self._render_resolved_parameters(change_set.resolved_parameters)
1✔
760
        return result
1✔
761

762
    @handler("ListChangeSets")
1✔
763
    def list_change_sets(
1✔
764
        self,
765
        context: RequestContext,
766
        stack_name: StackNameOrId,
767
        next_token: NextToken = None,
768
        **kwargs,
769
    ) -> ListChangeSetsOutput:
770
        store = get_cloudformation_store(account_id=context.account_id, region_name=context.region)
1✔
771
        stack = find_stack_v2(store, stack_name)
1✔
772
        if not stack:
1✔
773
            raise StackNotFoundError(stack_name)
×
774
        summaries = []
1✔
775
        for change_set_id in stack.change_set_ids:
1✔
776
            change_set = store.change_sets[change_set_id]
1✔
777
            if (
1✔
778
                change_set.status != ChangeSetStatus.CREATE_COMPLETE
779
                or change_set.execution_status != ExecutionStatus.AVAILABLE
780
            ):
781
                continue
1✔
782

783
            summaries.append(
1✔
784
                ChangeSetSummary(
785
                    StackId=change_set.stack.stack_id,
786
                    StackName=change_set.stack.stack_name,
787
                    ChangeSetId=change_set_id,
788
                    ChangeSetName=change_set.change_set_name,
789
                    ExecutionStatus=change_set.execution_status,
790
                    Status=change_set.status,
791
                    StatusReason=change_set.status_reason,
792
                    CreationTime=change_set.creation_time,
793
                    # mocked information
794
                    IncludeNestedStacks=False,
795
                )
796
            )
797

798
        return ListChangeSetsOutput(Summaries=summaries)
1✔
799

800
    @handler("DeleteChangeSet")
1✔
801
    def delete_change_set(
1✔
802
        self,
803
        context: RequestContext,
804
        change_set_name: ChangeSetNameOrId,
805
        stack_name: StackNameOrId = None,
806
        **kwargs,
807
    ) -> DeleteChangeSetOutput:
808
        state = get_cloudformation_store(context.account_id, context.region)
1✔
809
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
810
        if not change_set:
1✔
811
            return DeleteChangeSetOutput()
1✔
812

813
        try:
1✔
814
            change_set.stack.change_set_ids.remove(change_set.change_set_id)
1✔
815
        except KeyError:
×
816
            LOG.warning(
×
817
                "Could not disassociatei change set '%s' from stack '%s', it does not seem to be associated",
818
                change_set.change_set_id,
819
                change_set.stack.stack_id,
820
            )
821
        try:
1✔
822
            state.change_sets.pop(change_set.change_set_id)
1✔
823
        except KeyError:
×
824
            # This _should_ never fail since if we cannot find the change set in the store (using
825
            # `find_change_set_v2`) then we early return from this function
826
            LOG.warning(
×
827
                "Could not delete change set '%s', it does not exist", change_set.change_set_id
828
            )
829

830
        return DeleteChangeSetOutput()
1✔
831

832
    @handler("CreateStack", expand=False)
1✔
833
    def create_stack(self, context: RequestContext, request: CreateStackInput) -> CreateStackOutput:
1✔
834
        try:
1✔
835
            stack_name = request["StackName"]
1✔
836
        except KeyError:
×
837
            # TODO: proper exception
838
            raise ValidationError("StackName must be specified")
×
839

840
        state = get_cloudformation_store(context.account_id, context.region)
1✔
841

842
        active_stack_candidates = [
1✔
843
            stack
844
            for stack in state.stacks_v2.values()
845
            if stack.stack_name == stack_name and stack.status not in [StackStatus.DELETE_COMPLETE]
846
        ]
847

848
        # TODO: fix/implement this code path
849
        #   this needs more investigation how Cloudformation handles it (e.g. normal stack create or does it create a separate changeset?)
850
        # REVIEW_IN_PROGRESS is another special status
851
        # in this case existing changesets are set to obsolete and the stack is created
852
        # review_stack_candidates = [s for s in stack_candidates if s.status == StackStatus.REVIEW_IN_PROGRESS]
853
        # if review_stack_candidates:
854
        # set changesets to obsolete
855
        # for cs in review_stack_candidates[0].change_sets:
856
        #     cs.execution_status = ExecutionStatus.OBSOLETE
857

858
        if active_stack_candidates:
1✔
859
            raise AlreadyExistsException(f"Stack [{stack_name}] already exists")
1✔
860

861
        # TODO: copied from create_change_set, consider unifying
862
        template_body = request.get("TemplateBody")
1✔
863
        # s3 or secretsmanager url
864
        template_url = request.get("TemplateURL")
1✔
865

866
        # validate and resolve template
867
        if template_body and template_url:
1✔
868
            raise ValidationError(
×
869
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
870
            )  # TODO: check proper message
871

872
        if not template_body and not template_url:
1✔
873
            raise ValidationError(
×
874
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
875
            )  # TODO: check proper message
876

877
        template_body = api_utils.extract_template_body(request)
1✔
878
        structured_template = template_preparer.parse_template(template_body)
1✔
879

880
        if len(template_body) > 51200 and not template_url:
1✔
881
            raise ValidationError(
1✔
882
                f"1 validation error detected: Value '{template_body}' at 'templateBody' "
883
                "failed to satisfy constraint: Member must have length less than or equal to 51200"
884
            )
885

886
        if "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", []) and (
1✔
887
            "Transform" in structured_template.keys() or "Fn::Transform" in template_body
888
        ):
889
            raise InsufficientCapabilitiesException(
1✔
890
                "Requires capabilities : [CAPABILITY_AUTO_EXPAND]"
891
            )
892

893
        stack = Stack(
1✔
894
            account_id=context.account_id,
895
            region_name=context.region,
896
            request_payload=request,
897
            tags=request.get("Tags"),
898
        )
899
        # TODO: what is the correct initial status?
900
        state.stacks_v2[stack.stack_id] = stack
1✔
901

902
        # TODO: reconsider the way parameters are modelled in the update graph process.
903
        #  The options might be reduce to using the current style, or passing the extra information
904
        #  as a metadata object. The choice should be made considering when the extra information
905
        #  is needed for the update graph building, or only looked up in downstream tasks (metadata).
906
        request_parameters = request.get("Parameters", [])
1✔
907
        # TODO: handle parameter defaults and resolution
908
        after_parameters = self._extract_after_parameters(request_parameters)
1✔
909
        after_template = structured_template
1✔
910

911
        # Create internal change set to execute
912
        change_set = ChangeSet(
1✔
913
            stack,
914
            {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE},
915
            template=after_template,
916
            template_body=template_body,
917
        )
918
        self._setup_change_set_model(
1✔
919
            change_set=change_set,
920
            before_template=None,
921
            after_template=after_template,
922
            before_parameters=None,
923
            after_parameters=after_parameters,
924
            previous_update_model=None,
925
        )
926
        if change_set.status == ChangeSetStatus.FAILED:
1✔
927
            return CreateStackOutput(StackId=stack.stack_id)
1✔
928

929
        stack.processed_template = change_set.processed_template
1✔
930

931
        # deployment process
932
        stack.set_stack_status(StackStatus.CREATE_IN_PROGRESS)
1✔
933
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
934

935
        def _run(*args):
1✔
936
            try:
1✔
937
                result = change_set_executor.execute()
1✔
938
                stack.resolved_resources = result.resources
1✔
939
                stack.resolved_outputs = result.outputs
1✔
940
                if all(
1✔
941
                    resource["ResourceStatus"] == ResourceStatus.CREATE_COMPLETE
942
                    for resource in stack.resolved_resources.values()
943
                ):
944
                    stack.set_stack_status(StackStatus.CREATE_COMPLETE)
1✔
945
                else:
946
                    stack.set_stack_status(StackStatus.CREATE_FAILED)
1✔
947

948
                # if the deployment succeeded, update the stack's template representation to that
949
                # which was just deployed
950
                stack.template = change_set.template
1✔
951
                stack.template_body = change_set.template_body
1✔
952
                stack.processed_template = change_set.processed_template
1✔
953
                stack.resolved_parameters = change_set.resolved_parameters
1✔
954
                stack.resolved_exports = {}
1✔
955
                for output in result.outputs:
1✔
956
                    if export_name := output.get("ExportName"):
1✔
957
                        stack.resolved_exports[export_name] = output["OutputValue"]
1✔
958
            except Exception as e:
×
959
                LOG.error(
×
960
                    "Create Stack set failed: %s",
961
                    e,
962
                    exc_info=LOG.isEnabledFor(logging.WARNING) and config.CFN_VERBOSE_ERRORS,
963
                )
964
                stack.set_stack_status(StackStatus.CREATE_FAILED)
×
965

966
        start_worker_thread(_run)
1✔
967

968
        return CreateStackOutput(StackId=stack.stack_id)
1✔
969

970
    @handler("CreateStackSet", expand=False)
1✔
971
    def create_stack_set(
1✔
972
        self, context: RequestContext, request: CreateStackSetInput
973
    ) -> CreateStackSetOutput:
974
        state = get_cloudformation_store(context.account_id, context.region)
1✔
975
        stack_set = StackSet(context.account_id, context.region, request)
1✔
976
        state.stack_sets_v2[stack_set.stack_set_id] = stack_set
1✔
977

978
        return CreateStackSetOutput(StackSetId=stack_set.stack_set_id)
1✔
979

980
    @handler("DescribeStacks")
1✔
981
    def describe_stacks(
1✔
982
        self,
983
        context: RequestContext,
984
        stack_name: StackName = None,
985
        next_token: NextToken = None,
986
        **kwargs,
987
    ) -> DescribeStacksOutput:
988
        state = get_cloudformation_store(context.account_id, context.region)
1✔
989
        if stack_name:
1✔
990
            stack = find_stack_v2(state, stack_name)
1✔
991
            if not stack:
1✔
992
                raise ValidationError(f"Stack with id {stack_name} does not exist")
1✔
993
            stacks = [stack]
1✔
994
        else:
995
            stacks = state.stacks_v2.values()
1✔
996

997
        describe_stack_output: list[ApiStack] = []
1✔
998
        for stack in stacks:
1✔
999
            describe_stack_output.append(self._describe_stack(stack))
1✔
1000

1001
        return DescribeStacksOutput(Stacks=describe_stack_output)
1✔
1002

1003
    def _describe_stack(self, stack: Stack) -> ApiStack:
1✔
1004
        stack_description = ApiStack(
1✔
1005
            Description=stack.description,
1006
            CreationTime=stack.creation_time,
1007
            StackId=stack.stack_id,
1008
            StackName=stack.stack_name,
1009
            StackStatus=stack.status,
1010
            StackStatusReason=stack.status_reason,
1011
            # fake values
1012
            DisableRollback=False,
1013
            DriftInformation=StackDriftInformation(StackDriftStatus=StackDriftStatus.NOT_CHECKED),
1014
            EnableTerminationProtection=stack.enable_termination_protection,
1015
            RollbackConfiguration=RollbackConfiguration(),
1016
            Tags=stack.tags,
1017
            NotificationARNs=[],
1018
        )
1019
        if stack.status != StackStatus.REVIEW_IN_PROGRESS:
1✔
1020
            # TODO: actually track updated time
1021
            stack_description["LastUpdatedTime"] = stack.creation_time
1✔
1022
        if stack.deletion_time:
1✔
1023
            stack_description["DeletionTime"] = stack.deletion_time
1✔
1024
        if stack.capabilities:
1✔
1025
            stack_description["Capabilities"] = stack.capabilities
1✔
1026
        # TODO: confirm the logic for this
1027
        if change_set_id := stack.change_set_id:
1✔
1028
            stack_description["ChangeSetId"] = change_set_id
1✔
1029

1030
        if stack.resolved_parameters:
1✔
1031
            stack_description["Parameters"] = self._render_resolved_parameters(
1✔
1032
                stack.resolved_parameters
1033
            )
1034

1035
        if stack.resolved_outputs:
1✔
1036
            stack_description["Outputs"] = stack.resolved_outputs
1✔
1037

1038
        return stack_description
1✔
1039

1040
    @handler("ListStacks")
1✔
1041
    def list_stacks(
1✔
1042
        self,
1043
        context: RequestContext,
1044
        next_token: NextToken = None,
1045
        stack_status_filter: StackStatusFilter = None,
1046
        **kwargs,
1047
    ) -> ListStacksOutput:
1048
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1049

1050
        stacks = [
1✔
1051
            self._describe_stack(s)
1052
            for s in state.stacks_v2.values()
1053
            if not stack_status_filter or s.status in stack_status_filter
1054
        ]
1055

1056
        attrs = [
1✔
1057
            "StackId",
1058
            "StackName",
1059
            "TemplateDescription",
1060
            "CreationTime",
1061
            "LastUpdatedTime",
1062
            "DeletionTime",
1063
            "StackStatus",
1064
            "StackStatusReason",
1065
            "ParentId",
1066
            "RootId",
1067
            "DriftInformation",
1068
        ]
1069
        stacks = [select_attributes(stack, attrs) for stack in stacks]
1✔
1070
        return ListStacksOutput(StackSummaries=stacks)
1✔
1071

1072
    @handler("ListStackResources")
1✔
1073
    def list_stack_resources(
1✔
1074
        self, context: RequestContext, stack_name: StackName, next_token: NextToken = None, **kwargs
1075
    ) -> ListStackResourcesOutput:
1076
        result = self.describe_stack_resources(context, stack_name)
1✔
1077

1078
        resources = []
1✔
1079
        for resource in result.get("StackResources", []):
1✔
1080
            resources.append(
1✔
1081
                StackResourceSummary(
1082
                    LogicalResourceId=resource["LogicalResourceId"],
1083
                    PhysicalResourceId=resource["PhysicalResourceId"],
1084
                    ResourceType=resource["ResourceType"],
1085
                    LastUpdatedTimestamp=resource["Timestamp"],
1086
                    ResourceStatus=resource["ResourceStatus"],
1087
                    ResourceStatusReason=resource.get("ResourceStatusReason"),
1088
                    DriftInformation=resource.get("DriftInformation"),
1089
                    ModuleInfo=resource.get("ModuleInfo"),
1090
                )
1091
            )
1092

1093
        return ListStackResourcesOutput(StackResourceSummaries=resources)
1✔
1094

1095
    @handler("DescribeStackResource")
1✔
1096
    def describe_stack_resource(
1✔
1097
        self,
1098
        context: RequestContext,
1099
        stack_name: StackName,
1100
        logical_resource_id: LogicalResourceId,
1101
        **kwargs,
1102
    ) -> DescribeStackResourceOutput:
1103
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1104
        stack = find_stack_v2(state, stack_name)
1✔
1105
        if not stack:
1✔
1106
            raise StackNotFoundError(
1✔
1107
                stack_name, message_override=f"Stack '{stack_name}' does not exist"
1108
            )
1109

1110
        try:
1✔
1111
            resource = stack.resolved_resources[logical_resource_id]
1✔
1112
            if resource.get("ResourceStatus") not in [
1✔
1113
                StackStatus.CREATE_COMPLETE,
1114
                StackStatus.UPDATE_COMPLETE,
1115
                StackStatus.ROLLBACK_COMPLETE,
1116
            ]:
1117
                raise KeyError
×
1118
        except KeyError:
1✔
1119
            raise ValidationError(
1✔
1120
                f"Resource {logical_resource_id} does not exist for stack {stack_name}"
1121
            )
1122

1123
        resource_detail = StackResourceDetail(
1✔
1124
            StackName=stack.stack_name,
1125
            StackId=stack.stack_id,
1126
            LogicalResourceId=logical_resource_id,
1127
            PhysicalResourceId=resource["PhysicalResourceId"],
1128
            ResourceType=resource["Type"],
1129
            LastUpdatedTimestamp=resource["LastUpdatedTimestamp"],
1130
            ResourceStatus=resource["ResourceStatus"],
1131
            DriftInformation={"StackResourceDriftStatus": "NOT_CHECKED"},
1132
        )
1133
        return DescribeStackResourceOutput(StackResourceDetail=resource_detail)
1✔
1134

1135
    @handler("DescribeStackResources")
1✔
1136
    def describe_stack_resources(
1✔
1137
        self,
1138
        context: RequestContext,
1139
        stack_name: StackName = None,
1140
        logical_resource_id: LogicalResourceId = None,
1141
        physical_resource_id: PhysicalResourceId = None,
1142
        **kwargs,
1143
    ) -> DescribeStackResourcesOutput:
1144
        if physical_resource_id and stack_name:
1✔
1145
            raise ValidationError("Cannot specify both StackName and PhysicalResourceId")
×
1146
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1147
        stack = find_stack_v2(state, stack_name)
1✔
1148
        if not stack:
1✔
1149
            raise StackNotFoundError(stack_name)
×
1150
        # TODO: filter stack by PhysicalResourceId!
1151
        statuses = []
1✔
1152
        for resource_id, resource_status in stack.resource_states.items():
1✔
1153
            if resource_id == logical_resource_id or logical_resource_id is None:
1✔
1154
                status = copy.deepcopy(resource_status)
1✔
1155
                status.setdefault("DriftInformation", {"StackResourceDriftStatus": "NOT_CHECKED"})
1✔
1156
                statuses.append(status)
1✔
1157
        return DescribeStackResourcesOutput(StackResources=statuses)
1✔
1158

1159
    @handler("CreateStackInstances", expand=False)
1✔
1160
    def create_stack_instances(
1✔
1161
        self,
1162
        context: RequestContext,
1163
        request: CreateStackInstancesInput,
1164
    ) -> CreateStackInstancesOutput:
1165
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1166

1167
        stack_set_name = request["StackSetName"]
1✔
1168
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1169
        if not stack_set:
1✔
1170
            raise StackSetNotFoundError(stack_set_name)
1✔
1171

1172
        op_id = request.get("OperationId") or short_uid()
1✔
1173
        accounts = request["Accounts"]
1✔
1174
        regions = request["Regions"]
1✔
1175

1176
        stacks_to_await = []
1✔
1177
        for account in accounts:
1✔
1178
            for region in regions:
1✔
1179
                # deploy new stack
1180
                LOG.debug(
1✔
1181
                    'Deploying instance for stack set "%s" in account: %s region %s',
1182
                    stack_set_name,
1183
                    account,
1184
                    region,
1185
                )
1186
                cf_client = connect_to(aws_access_key_id=account, region_name=region).cloudformation
1✔
1187
                if stack_set.template_body:
1✔
1188
                    kwargs = {
1✔
1189
                        "TemplateBody": stack_set.template_body,
1190
                    }
1191
                elif stack_set.template_url:
×
1192
                    kwargs = {
×
1193
                        "TemplateURL": stack_set.template_url,
1194
                    }
1195
                else:
1196
                    # TODO: wording
1197
                    raise ValueError("Neither StackSet Template URL nor TemplateBody provided")
×
1198
                stack_name = f"sset-{stack_set_name}-{account}-{region}"
1✔
1199

1200
                # skip creation of existing stacks
1201
                if find_stack_v2(state, stack_name):
1✔
1202
                    continue
1✔
1203

1204
                result = cf_client.create_stack(StackName=stack_name, **kwargs)
1✔
1205
                # store stack instance
1206
                stack_instance = StackInstance(
1✔
1207
                    account_id=account,
1208
                    region_name=region,
1209
                    stack_set_id=stack_set.stack_set_id,
1210
                    operation_id=op_id,
1211
                    stack_id=result["StackId"],
1212
                )
1213
                stack_set.stack_instances.append(stack_instance)
1✔
1214

1215
                stacks_to_await.append((stack_name, account, region))
1✔
1216

1217
        # wait for completion of stack
1218
        for stack_name, account_id, region_name in stacks_to_await:
1✔
1219
            client = connect_to(
1✔
1220
                aws_access_key_id=account_id, region_name=region_name
1221
            ).cloudformation
1222
            client.get_waiter("stack_create_complete").wait(StackName=stack_name)
1✔
1223

1224
        # record operation
1225
        operation = StackSetOperation(
1✔
1226
            OperationId=op_id,
1227
            StackSetId=stack_set.stack_set_id,
1228
            Action=StackSetOperationAction.CREATE,
1229
            Status=StackSetOperationStatus.SUCCEEDED,
1230
        )
1231
        stack_set.operations[op_id] = operation
1✔
1232

1233
        return CreateStackInstancesOutput(OperationId=op_id)
1✔
1234

1235
    @handler("DescribeStackSetOperation")
1✔
1236
    def describe_stack_set_operation(
1✔
1237
        self,
1238
        context: RequestContext,
1239
        stack_set_name: StackSetName,
1240
        operation_id: ClientRequestToken,
1241
        call_as: CallAs = None,
1242
        **kwargs,
1243
    ) -> DescribeStackSetOperationOutput:
1244
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1245
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1246
        if not stack_set:
1✔
1247
            raise StackSetNotFoundError(stack_set_name)
×
1248

1249
        result = stack_set.operations.get(operation_id)
1✔
1250
        if not result:
1✔
1251
            LOG.debug(
×
1252
                'Unable to find operation ID "%s" for stack set "%s" in list: %s',
1253
                operation_id,
1254
                stack_set_name,
1255
                list(stack_set.operations.keys()),
1256
            )
1257
            # TODO: proper exception
1258
            raise ValueError(
×
1259
                f'Unable to find operation ID "{operation_id}" for stack set "{stack_set_name}"'
1260
            )
1261

1262
        return DescribeStackSetOperationOutput(StackSetOperation=result)
1✔
1263

1264
    @handler("DeleteStackInstances", expand=False)
1✔
1265
    def delete_stack_instances(
1✔
1266
        self,
1267
        context: RequestContext,
1268
        request: DeleteStackInstancesInput,
1269
    ) -> DeleteStackInstancesOutput:
1270
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1271

1272
        stack_set_name = request["StackSetName"]
1✔
1273
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1274
        if not stack_set:
1✔
1275
            raise StackSetNotFoundError(stack_set_name)
×
1276

1277
        op_id = request.get("OperationId") or short_uid()
1✔
1278

1279
        accounts = request["Accounts"]
1✔
1280
        regions = request["Regions"]
1✔
1281

1282
        operations_to_await = []
1✔
1283
        for account in accounts:
1✔
1284
            for region in regions:
1✔
1285
                cf_client = connect_to(aws_access_key_id=account, region_name=region).cloudformation
1✔
1286
                instance = find_stack_instance(stack_set, account, region)
1✔
1287

1288
                # TODO: check parity with AWS
1289
                # TODO: delete stack instance?
1290
                if not instance:
1✔
1291
                    continue
×
1292

1293
                cf_client.delete_stack(StackName=instance.stack_id)
1✔
1294
                operations_to_await.append(instance)
1✔
1295

1296
        for instance in operations_to_await:
1✔
1297
            cf_client = connect_to(
1✔
1298
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1299
            ).cloudformation
1300
            cf_client.get_waiter("stack_delete_complete").wait(StackName=instance.stack_id)
1✔
1301
            stack_set.stack_instances.remove(instance)
1✔
1302

1303
        # record operation
1304
        operation = StackSetOperation(
1✔
1305
            OperationId=op_id,
1306
            StackSetId=stack_set.stack_set_id,
1307
            Action=StackSetOperationAction.DELETE,
1308
            Status=StackSetOperationStatus.SUCCEEDED,
1309
        )
1310
        stack_set.operations[op_id] = operation
1✔
1311

1312
        return DeleteStackInstancesOutput(OperationId=op_id)
1✔
1313

1314
    @handler("DeleteStackSet")
1✔
1315
    def delete_stack_set(
1✔
1316
        self,
1317
        context: RequestContext,
1318
        stack_set_name: StackSetName,
1319
        call_as: CallAs = None,
1320
        **kwargs,
1321
    ) -> DeleteStackSetOutput:
1322
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1323
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1324
        if not stack_set:
1✔
1325
            # operation is idempotent
1326
            return DeleteStackSetOutput()
1✔
1327

1328
        # clean up any left-over instances
1329
        operations_to_await = []
1✔
1330
        for instance in stack_set.stack_instances:
1✔
1331
            cf_client = connect_to(
×
1332
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1333
            ).cloudformation
1334
            cf_client.delete_stack(StackName=instance.stack_id)
×
1335
            operations_to_await.append(instance)
×
1336

1337
        for instance in operations_to_await:
1✔
1338
            cf_client = connect_to(
×
1339
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1340
            ).cloudformation
1341
            cf_client.get_waiter("stack_delete_complete").wait(StackName=instance.stack_id)
×
1342
            stack_set.stack_instances.remove(instance)
×
1343

1344
        state.stack_sets_v2.pop(stack_set.stack_set_id)
1✔
1345

1346
        return DeleteStackSetOutput()
1✔
1347

1348
    @handler("DescribeStackEvents")
1✔
1349
    def describe_stack_events(
1✔
1350
        self,
1351
        context: RequestContext,
1352
        stack_name: StackName,
1353
        next_token: NextToken | None = None,
1354
        **kwargs,
1355
    ) -> DescribeStackEventsOutput:
1356
        if not stack_name:
1✔
1357
            raise ValidationError(
×
1358
                "1 validation error detected: Value null at 'stackName' failed to satisfy constraint: Member must not be null"
1359
            )
1360
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1361
        stack = find_stack_v2(state, stack_name)
1✔
1362
        if not stack:
1✔
1363
            raise StackNotFoundError(stack_name)
1✔
1364
        return DescribeStackEventsOutput(StackEvents=stack.events)
1✔
1365

1366
    @handler("GetTemplate")
1✔
1367
    def get_template(
1✔
1368
        self,
1369
        context: RequestContext,
1370
        stack_name: StackName = None,
1371
        change_set_name: ChangeSetNameOrId = None,
1372
        template_stage: TemplateStage = None,
1373
        **kwargs,
1374
    ) -> GetTemplateOutput:
1375
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1376
        if change_set_name:
1✔
1377
            if not is_changeset_arn(change_set_name) and not stack_name:
1✔
1378
                raise ValidationError("StackName is a required parameter.")
1✔
1379

1380
            change_set = find_change_set_v2(state, change_set_name, stack_name=stack_name)
1✔
1381
            if not change_set:
1✔
1382
                raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
1✔
1383
            stack = change_set.stack
×
1384
        elif stack_name:
1✔
1385
            stack = find_stack_v2(state, stack_name)
1✔
1386
            if not stack:
1✔
1387
                raise StackNotFoundError(
1✔
1388
                    stack_name, message_override=f"Stack with id {stack_name} does not exist"
1389
                )
1390
        else:
1391
            raise ValidationError("StackName is required if ChangeSetName is not specified.")
1✔
1392

1393
        if template_stage == TemplateStage.Processed and "Transform" in stack.template_body:
1✔
1394
            template_body = json.dumps(stack.processed_template)
1✔
1395
        else:
1396
            template_body = stack.template_body
1✔
1397

1398
        return GetTemplateOutput(
1✔
1399
            TemplateBody=template_body,
1400
            StagesAvailable=[TemplateStage.Original, TemplateStage.Processed],
1401
        )
1402

1403
    @handler("GetTemplateSummary", expand=False)
1✔
1404
    def get_template_summary(
1✔
1405
        self,
1406
        context: RequestContext,
1407
        request: GetTemplateSummaryInput,
1408
    ) -> GetTemplateSummaryOutput:
1409
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1410
        stack_name = request.get("StackName")
1✔
1411

1412
        if stack_name:
1✔
1413
            stack = find_stack_v2(state, stack_name)
1✔
1414
            if not stack:
1✔
1415
                raise StackNotFoundError(stack_name)
×
1416

1417
            if stack.status == StackStatus.REVIEW_IN_PROGRESS:
1✔
1418
                raise ValidationError(
1✔
1419
                    "GetTemplateSummary cannot be called on REVIEW_IN_PROGRESS stacks."
1420
                )
1421

1422
            template = stack.template
1✔
1423
        else:
1424
            template_body = request.get("TemplateBody")
1✔
1425
            # s3 or secretsmanager url
1426
            template_url = request.get("TemplateURL")
1✔
1427

1428
            # validate and resolve template
1429
            if template_body and template_url:
1✔
1430
                raise ValidationError(
×
1431
                    "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1432
                )  # TODO: check proper message
1433

1434
            if not template_body and not template_url:
1✔
1435
                raise ValidationError(
×
1436
                    "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1437
                )  # TODO: check proper message
1438

1439
            template_body = api_utils.extract_template_body(request)
1✔
1440
            template = template_preparer.parse_template(template_body)
1✔
1441

1442
        id_summaries = defaultdict(list)
1✔
1443
        if "Resources" not in template:
1✔
1444
            raise ValidationError(
1✔
1445
                "Template format error: At least one Resources member must be defined."
1446
            )
1447

1448
        for resource_id, resource in template["Resources"].items():
1✔
1449
            res_type = resource["Type"]
1✔
1450
            id_summaries[res_type].append(resource_id)
1✔
1451

1452
        summarized_parameters = []
1✔
1453
        for parameter_id, parameter_body in template.get("Parameters", {}).items():
1✔
1454
            summarized_parameters.append(
1✔
1455
                {
1456
                    "ParameterKey": parameter_id,
1457
                    "DefaultValue": parameter_body.get("Default"),
1458
                    "ParameterType": parameter_body["Type"],
1459
                    "Description": parameter_body.get("Description"),
1460
                }
1461
            )
1462
        result = GetTemplateSummaryOutput(
1✔
1463
            Parameters=summarized_parameters,
1464
            Metadata=template.get("Metadata"),
1465
            ResourceIdentifierSummaries=[
1466
                {"ResourceType": key, "LogicalResourceIds": values}
1467
                for key, values in id_summaries.items()
1468
            ],
1469
            ResourceTypes=list(id_summaries.keys()),
1470
            Version=template.get("AWSTemplateFormatVersion", "2010-09-09"),
1471
        )
1472

1473
        return result
1✔
1474

1475
    @handler("UpdateTerminationProtection")
1✔
1476
    def update_termination_protection(
1✔
1477
        self,
1478
        context: RequestContext,
1479
        enable_termination_protection: EnableTerminationProtection,
1480
        stack_name: StackNameOrId,
1481
        **kwargs,
1482
    ) -> UpdateTerminationProtectionOutput:
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
            raise StackNotFoundError(stack_name)
×
1487

1488
        stack.enable_termination_protection = enable_termination_protection
1✔
1489
        return UpdateTerminationProtectionOutput(StackId=stack.stack_id)
1✔
1490

1491
    @handler("UpdateStack", expand=False)
1✔
1492
    def update_stack(
1✔
1493
        self,
1494
        context: RequestContext,
1495
        request: UpdateStackInput,
1496
    ) -> UpdateStackOutput:
1497
        try:
1✔
1498
            stack_name = request["StackName"]
1✔
1499
        except KeyError:
×
1500
            # TODO: proper exception
1501
            raise ValidationError("StackName must be specified")
×
1502
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1503
        template_body = request.get("TemplateBody")
1✔
1504
        # s3 or secretsmanager url
1505
        template_url = request.get("TemplateURL")
1✔
1506

1507
        # validate and resolve template
1508
        if template_body and template_url:
1✔
1509
            raise ValidationError(
×
1510
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1511
            )  # TODO: check proper message
1512

1513
        if not template_body and not template_url:
1✔
1514
            raise ValidationError(
×
1515
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1516
            )  # TODO: check proper message
1517

1518
        template_body = api_utils.extract_template_body(request)
1✔
1519
        structured_template = template_preparer.parse_template(template_body)
1✔
1520

1521
        if "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", []) and (
1✔
1522
            "Transform" in structured_template.keys() or "Fn::Transform" in template_body
1523
        ):
1524
            raise InsufficientCapabilitiesException(
×
1525
                "Requires capabilities : [CAPABILITY_AUTO_EXPAND]"
1526
            )
1527

1528
        # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing
1529
        # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet)
1530
        stack: Stack
1531
        if is_stack_arn(stack_name):
1✔
1532
            stack = state.stacks_v2.get(stack_name)
×
1533
            if not stack:
×
1534
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
1535

1536
        else:
1537
            # stack name specified, so fetch the stack by name
1538
            stack_candidates: list[Stack] = [
1✔
1539
                s for stack_arn, s in state.stacks_v2.items() if s.stack_name == stack_name
1540
            ]
1541
            active_stack_candidates = [
1✔
1542
                s for s in stack_candidates if self._stack_status_is_active(s.status)
1543
            ]
1544

1545
            if not active_stack_candidates:
1✔
1546
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
1547
            elif len(active_stack_candidates) > 1:
1✔
1548
                raise RuntimeError("Multiple stacks matched, update matching logic")
×
1549
            stack = active_stack_candidates[0]
1✔
1550

1551
        if (
1✔
1552
            stack.status == StackStatus.DELETE_COMPLETE
1553
            or stack.status == StackStatus.DELETE_IN_PROGRESS
1554
        ):
1555
            raise ValidationError(
×
1556
                f"Stack:{stack.stack_id} is in {stack.status} state and can not be updated."
1557
            )
1558

1559
        # TODO: proper status modeling
1560
        before_parameters = stack.resolved_parameters
1✔
1561
        # TODO: reconsider the way parameters are modelled in the update graph process.
1562
        #  The options might be reduce to using the current style, or passing the extra information
1563
        #  as a metadata object. The choice should be made considering when the extra information
1564
        #  is needed for the update graph building, or only looked up in downstream tasks (metadata).
1565
        request_parameters = request.get("Parameters", [])
1✔
1566
        # TODO: handle parameter defaults and resolution
1567
        after_parameters = self._extract_after_parameters(request_parameters, before_parameters)
1✔
1568

1569
        before_template = stack.template
1✔
1570
        after_template = structured_template
1✔
1571

1572
        previous_update_model = None
1✔
1573
        if stack.change_set_id:
1✔
1574
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
1575
                previous_update_model = previous_change_set.update_model
1✔
1576

1577
        change_set = ChangeSet(
1✔
1578
            stack,
1579
            {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE},
1580
            template_body=template_body,
1581
            template=after_template,
1582
        )
1583
        self._setup_change_set_model(
1✔
1584
            change_set=change_set,
1585
            before_template=before_template,
1586
            after_template=after_template,
1587
            before_parameters=before_parameters,
1588
            after_parameters=after_parameters,
1589
            previous_update_model=previous_update_model,
1590
        )
1591

1592
        # TODO: some changes are only detectable at runtime; consider using
1593
        #       the ChangeSetModelDescriber, or a new custom visitors, to
1594
        #       pick-up on runtime changes.
1595
        if not change_set.has_changes():
1✔
1596
            raise ValidationError("No updates are to be performed.")
1✔
1597

1598
        stack.set_stack_status(StackStatus.UPDATE_IN_PROGRESS)
1✔
1599
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
1600

1601
        def _run(*args):
1✔
1602
            try:
1✔
1603
                result = change_set_executor.execute()
1✔
1604
                stack.set_stack_status(StackStatus.UPDATE_COMPLETE)
1✔
1605
                stack.resolved_resources = result.resources
1✔
1606
                stack.resolved_outputs = result.outputs
1✔
1607
                # if the deployment succeeded, update the stack's template representation to that
1608
                # which was just deployed
1609
                stack.template = change_set.template
1✔
1610
                stack.template_body = change_set.template_body
1✔
1611
                stack.resolved_parameters = change_set.resolved_parameters
1✔
1612
                stack.resolved_exports = {}
1✔
1613
                for output in result.outputs:
1✔
1614
                    if export_name := output.get("ExportName"):
1✔
1615
                        stack.resolved_exports[export_name] = output["OutputValue"]
1✔
1616
            except Exception as e:
×
1617
                LOG.error(
×
1618
                    "Update Stack failed: %s",
1619
                    e,
1620
                    exc_info=LOG.isEnabledFor(logging.WARNING) and config.CFN_VERBOSE_ERRORS,
1621
                )
1622
                stack.set_stack_status(StackStatus.UPDATE_FAILED)
×
1623

1624
        start_worker_thread(_run)
1✔
1625

1626
        return UpdateStackOutput(StackId=stack.stack_id)
1✔
1627

1628
    @staticmethod
1✔
1629
    def _extract_after_parameters(
1✔
1630
        request_parameters, before_parameters: dict[str, str] | None = None
1631
    ) -> dict[str, str]:
1632
        before_parameters = before_parameters or {}
1✔
1633
        after_parameters = {}
1✔
1634
        for parameter in request_parameters:
1✔
1635
            key = parameter["ParameterKey"]
1✔
1636
            if parameter.get("UsePreviousValue", False):
1✔
1637
                # todo: what if the parameter does not exist in the before parameters
1638
                before = before_parameters[key]
1✔
1639
                after_parameters[key] = (
1✔
1640
                    before.get("resolved_value")
1641
                    or before.get("given_value")
1642
                    or before.get("default_value")
1643
                )
1644
                continue
1✔
1645

1646
            if "ParameterValue" in parameter:
1✔
1647
                after_parameters[key] = parameter["ParameterValue"]
1✔
1648
                continue
1✔
1649
        return after_parameters
1✔
1650

1651
    @handler("DeleteStack")
1✔
1652
    def delete_stack(
1✔
1653
        self,
1654
        context: RequestContext,
1655
        stack_name: StackName,
1656
        retain_resources: RetainResources = None,
1657
        role_arn: RoleARN = None,
1658
        client_request_token: ClientRequestToken = None,
1659
        deletion_mode: DeletionMode = None,
1660
        **kwargs,
1661
    ) -> None:
1662
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1663
        stack = find_stack_v2(state, stack_name)
1✔
1664
        if not stack:
1✔
1665
            # aws will silently ignore invalid stack names - we should do the same
1666
            return
1✔
1667

1668
        # shortcut for stacks which have no deployed resources i.e. where a change set was
1669
        # created, but never executed
1670
        if stack.status == StackStatus.REVIEW_IN_PROGRESS and not stack.resolved_resources:
1✔
1671
            stack.set_stack_status(StackStatus.DELETE_COMPLETE)
1✔
1672
            stack.deletion_time = datetime.now(tz=UTC)
1✔
1673
            return
1✔
1674

1675
        stack.set_stack_status(StackStatus.DELETE_IN_PROGRESS)
1✔
1676

1677
        # create a dummy change set
1678
        change_set = ChangeSet(
1✔
1679
            stack, {"ChangeSetName": f"delete-stack_{stack.stack_name}"}, template_body=""
1680
        )  # noqa
1681
        self._setup_change_set_model(
1✔
1682
            change_set=change_set,
1683
            before_template=stack.processed_template,
1684
            after_template=None,
1685
            before_parameters=stack.resolved_parameters,
1686
            after_parameters=None,
1687
        )
1688

1689
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
1690

1691
        def _run(*args):
1✔
1692
            try:
1✔
1693
                change_set_executor.execute()
1✔
1694
                stack.set_stack_status(StackStatus.DELETE_COMPLETE)
1✔
1695
                stack.deletion_time = datetime.now(tz=UTC)
1✔
1696
            except Exception as e:
×
1697
                LOG.warning(
×
1698
                    "Failed to delete stack '%s': %s",
1699
                    stack.stack_name,
1700
                    e,
1701
                    exc_info=LOG.isEnabledFor(logging.DEBUG) and config.CFN_VERBOSE_ERRORS,
1702
                )
1703
                stack.set_stack_status(StackStatus.DELETE_FAILED)
×
1704

1705
        start_worker_thread(_run)
1✔
1706
        return ExecuteChangeSetOutput()
1✔
1707

1708
    @handler("ListExports")
1✔
1709
    def list_exports(
1✔
1710
        self, context: RequestContext, next_token: NextToken = None, **kwargs
1711
    ) -> ListExportsOutput:
1712
        store = get_cloudformation_store(account_id=context.account_id, region_name=context.region)
1✔
1713
        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