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

localstack / localstack / 17029583506

15 Aug 2025 04:45AM UTC coverage: 86.902% (+0.006%) from 86.896%
17029583506

push

github

web-flow
CFNV2: handle AWS::NoValue (#13000)

3 of 4 new or added lines in 2 files covered. (75.0%)

88 existing lines in 7 files now uncovered.

66940 of 77029 relevant lines covered (86.9%)

0.87 hits per line

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

89.72
/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
    RetainExceptOnCreate,
56
    RetainResources,
57
    RoleARN,
58
    RollbackConfiguration,
59
    StackDriftInformation,
60
    StackDriftStatus,
61
    StackName,
62
    StackNameOrId,
63
    StackResourceDetail,
64
    StackResourceSummary,
65
    StackSetName,
66
    StackSetNotFoundException,
67
    StackSetOperation,
68
    StackSetOperationAction,
69
    StackSetOperationStatus,
70
    StackStatus,
71
    StackStatusFilter,
72
    TemplateStage,
73
    UpdateStackInput,
74
    UpdateStackOutput,
75
    UpdateTerminationProtectionOutput,
76
)
77
from localstack.aws.api.cloudformation import (
1✔
78
    Stack as ApiStack,
79
)
80
from localstack.aws.connect import connect_to
1✔
81
from localstack.services.cloudformation import api_utils
1✔
82
from localstack.services.cloudformation.engine import template_preparer
1✔
83
from localstack.services.cloudformation.engine.parameters import resolve_ssm_parameter
1✔
84
from localstack.services.cloudformation.engine.transformers import FailedTransformationException
1✔
85
from localstack.services.cloudformation.engine.v2.change_set_model import (
1✔
86
    ChangeSetModel,
87
    ChangeType,
88
    UpdateModel,
89
)
90
from localstack.services.cloudformation.engine.v2.change_set_model_describer import (
1✔
91
    ChangeSetModelDescriber,
92
)
93
from localstack.services.cloudformation.engine.v2.change_set_model_executor import (
1✔
94
    ChangeSetModelExecutor,
95
)
96
from localstack.services.cloudformation.engine.v2.change_set_model_transform import (
1✔
97
    ChangeSetModelTransform,
98
)
99
from localstack.services.cloudformation.engine.v2.change_set_model_validator import (
1✔
100
    ChangeSetModelValidator,
101
)
102
from localstack.services.cloudformation.engine.validations import ValidationError
1✔
103
from localstack.services.cloudformation.provider import (
1✔
104
    ARN_CHANGESET_REGEX,
105
    ARN_STACK_REGEX,
106
    ARN_STACK_SET_REGEX,
107
    CloudformationProvider,
108
)
109
from localstack.services.cloudformation.stores import (
1✔
110
    CloudFormationStore,
111
    get_cloudformation_store,
112
)
113
from localstack.services.cloudformation.v2.entities import (
1✔
114
    ChangeSet,
115
    Stack,
116
    StackInstance,
117
    StackSet,
118
)
119
from localstack.services.cloudformation.v2.types import EngineParameter
1✔
120
from localstack.utils.collections import select_attributes
1✔
121
from localstack.utils.strings import short_uid
1✔
122
from localstack.utils.threads import start_worker_thread
1✔
123

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

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

130

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

134

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

138

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

142

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

153

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

158

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

177

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

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

198

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

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

207
    return None
1✔
208

209

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

216

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

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

260
            resolved_parameters[name] = resolved_parameter
1✔
261

262
        if invalid_parameters:
1✔
263
            raise ValidationError(f"Parameters: [{','.join(invalid_parameters)}] must have values")
1✔
264

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

275
        return resolved_parameters
1✔
276

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

296
        change_set.resolved_parameters = resolved_parameters
1✔
297

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

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

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

350
        # perform validations
351
        validator = ChangeSetModelValidator(
1✔
352
            change_set=change_set,
353
        )
354
        validator.validate()
1✔
355

356
        change_set.set_update_model(update_model)
1✔
357
        change_set.processed_template = transformed_after_template
1✔
358

359
    @handler("CreateChangeSet", expand=False)
1✔
360
    def create_change_set(
1✔
361
        self, context: RequestContext, request: CreateChangeSetInput
362
    ) -> CreateChangeSetOutput:
363
        try:
1✔
364
            stack_name = request["StackName"]
1✔
UNCOV
365
        except KeyError:
×
366
            # TODO: proper exception
UNCOV
367
            raise ValidationError("StackName must be specified")
×
368
        try:
1✔
369
            change_set_name = request["ChangeSetName"]
1✔
UNCOV
370
        except KeyError:
×
371
            # TODO: proper exception
UNCOV
372
            raise ValidationError("StackName must be specified")
×
373

374
        state = get_cloudformation_store(context.account_id, context.region)
1✔
375

376
        change_set_type = request.get("ChangeSetType", "UPDATE")
1✔
377
        template_body = request.get("TemplateBody")
1✔
378
        # s3 or secretsmanager url
379
        template_url = request.get("TemplateURL")
1✔
380

381
        # validate and resolve template
382
        if template_body and template_url:
1✔
UNCOV
383
            raise ValidationError(
×
384
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
385
            )  # TODO: check proper message
386

387
        if not template_body and not template_url:
1✔
UNCOV
388
            raise ValidationError(
×
389
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
390
            )  # TODO: check proper message
391

392
        template_body = api_utils.extract_template_body(request)
1✔
393
        structured_template = template_preparer.parse_template(template_body)
1✔
394

395
        if len(template_body) > 51200:
1✔
UNCOV
396
            raise ValidationError(
×
397
                f"1 validation error detected: Value '{template_body}' at 'templateBody' "
398
                "failed to satisfy constraint: Member must have length less than or equal to 51200"
399
            )
400

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

414
            # on a CREATE an empty Stack should be generated if we didn't find an active one
415
            if not active_stack_candidates and change_set_type == ChangeSetType.CREATE:
1✔
416
                stack = Stack(
1✔
417
                    account_id=context.account_id,
418
                    region_name=context.region,
419
                    request_payload=request,
420
                    initial_status=StackStatus.REVIEW_IN_PROGRESS,
421
                )
422
                state.stacks_v2[stack.stack_id] = stack
1✔
423
            else:
424
                if not active_stack_candidates:
1✔
425
                    raise ValidationError(f"Stack '{stack_name}' does not exist.")
1✔
426
                stack = active_stack_candidates[0]
1✔
427

428
        # TODO: test if rollback status is allowed as well
429
        if (
1✔
430
            change_set_type == ChangeSetType.CREATE
431
            and stack.status != StackStatus.REVIEW_IN_PROGRESS
432
        ):
433
            raise ValidationError(
1✔
434
                f"Stack [{stack_name}] already exists and cannot be created again with the changeSet [{change_set_name}]."
435
            )
436

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

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

465
        # TODO: update this logic to always pass the clean template object if one exists. The
466
        #  current issue with relaying on stack.template_original is that this appears to have
467
        #  its parameters and conditions populated.
468
        before_template = None
1✔
469
        if change_set_type == ChangeSetType.UPDATE:
1✔
470
            before_template = stack.template
1✔
471
        after_template = structured_template
1✔
472

473
        previous_update_model = None
1✔
474
        try:
1✔
475
            # FIXME: 'change_set_id' for 'stack' objects is dynamically attributed
476
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
477
                previous_update_model = previous_change_set.update_model
1✔
478
        except Exception:
1✔
479
            # No change set available on this stack.
480
            pass
1✔
481

482
        # create change set for the stack and apply changes
483
        change_set = ChangeSet(stack, request, template=after_template, template_body=template_body)
1✔
484
        self._setup_change_set_model(
1✔
485
            change_set=change_set,
486
            before_template=before_template,
487
            after_template=after_template,
488
            before_parameters=before_parameters,
489
            after_parameters=after_parameters,
490
            previous_update_model=previous_update_model,
491
        )
492

493
        # TODO: handle the empty change set case
494
        if not change_set.has_changes():
1✔
495
            change_set.set_change_set_status(ChangeSetStatus.FAILED)
1✔
496
            change_set.set_execution_status(ExecutionStatus.UNAVAILABLE)
1✔
497
            change_set.status_reason = "The submitted information didn't contain changes. Submit different information to create a change set."
1✔
498
        else:
499
            if stack.status in [StackStatus.CREATE_COMPLETE, StackStatus.UPDATE_COMPLETE]:
1✔
500
                stack.set_stack_status(StackStatus.UPDATE_IN_PROGRESS)
1✔
501
            else:
502
                stack.set_stack_status(StackStatus.REVIEW_IN_PROGRESS)
1✔
503

504
            change_set.set_change_set_status(ChangeSetStatus.CREATE_COMPLETE)
1✔
505

506
        stack.change_set_ids.append(change_set.change_set_id)
1✔
507
        state.change_sets[change_set.change_set_id] = change_set
1✔
508

509
        return CreateChangeSetOutput(StackId=stack.stack_id, Id=change_set.change_set_id)
1✔
510

511
    @handler("ExecuteChangeSet")
1✔
512
    def execute_change_set(
1✔
513
        self,
514
        context: RequestContext,
515
        change_set_name: ChangeSetNameOrId,
516
        stack_name: StackNameOrId | None = None,
517
        client_request_token: ClientRequestToken | None = None,
518
        disable_rollback: DisableRollback | None = None,
519
        retain_except_on_create: RetainExceptOnCreate | None = None,
520
        **kwargs,
521
    ) -> ExecuteChangeSetOutput:
522
        state = get_cloudformation_store(context.account_id, context.region)
1✔
523

524
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
525
        if not change_set:
1✔
UNCOV
526
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
×
527

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

542
        change_set.set_execution_status(ExecutionStatus.EXECUTE_IN_PROGRESS)
1✔
543
        change_set.stack.set_stack_status(
1✔
544
            StackStatus.UPDATE_IN_PROGRESS
545
            if change_set.change_set_type == ChangeSetType.UPDATE
546
            else StackStatus.CREATE_IN_PROGRESS
547
        )
548

549
        change_set_executor = ChangeSetModelExecutor(
1✔
550
            change_set,
551
        )
552

553
        def _run(*args):
1✔
554
            try:
1✔
555
                result = change_set_executor.execute()
1✔
556
                new_stack_status = StackStatus.UPDATE_COMPLETE
1✔
557
                if change_set.change_set_type == ChangeSetType.CREATE:
1✔
558
                    new_stack_status = StackStatus.CREATE_COMPLETE
1✔
559
                change_set.stack.set_stack_status(new_stack_status)
1✔
560
                change_set.set_execution_status(ExecutionStatus.EXECUTE_COMPLETE)
1✔
561
                change_set.stack.resolved_resources = result.resources
1✔
562
                change_set.stack.resolved_parameters = change_set.resolved_parameters
1✔
563
                change_set.stack.resolved_outputs = result.outputs
1✔
564

565
                change_set.stack.resolved_exports = {}
1✔
566
                for output in result.outputs:
1✔
567
                    if export_name := output.get("ExportName"):
1✔
568
                        change_set.stack.resolved_exports[export_name] = output["OutputValue"]
1✔
569

570
                change_set.stack.change_set_id = change_set.change_set_id
1✔
571
                change_set.stack.change_set_ids.append(change_set.change_set_id)
1✔
572

573
                # if the deployment succeeded, update the stack's template representation to that
574
                # which was just deployed
575
                change_set.stack.template = change_set.template
1✔
576
                change_set.stack.description = change_set.template.get("Description")
1✔
577
                change_set.stack.processed_template = change_set.processed_template
1✔
578
                change_set.stack.template_body = change_set.template_body
1✔
579
            except Exception as e:
1✔
580
                LOG.error(
1✔
581
                    "Execute change set failed: %s",
582
                    e,
583
                    exc_info=LOG.isEnabledFor(logging.DEBUG) and config.CFN_VERBOSE_ERRORS,
584
                )
585
                new_stack_status = StackStatus.UPDATE_FAILED
1✔
586
                if change_set.change_set_type == ChangeSetType.CREATE:
1✔
587
                    new_stack_status = StackStatus.CREATE_FAILED
1✔
588

589
                change_set.stack.set_stack_status(new_stack_status)
1✔
590
                change_set.set_execution_status(ExecutionStatus.EXECUTE_FAILED)
1✔
591
                change_set.stack.change_set_id = change_set.change_set_id
1✔
592
                change_set.stack.change_set_ids.append(change_set.change_set_id)
1✔
593

594
        start_worker_thread(_run)
1✔
595

596
        return ExecuteChangeSetOutput()
1✔
597

598
    def _describe_change_set(
1✔
599
        self, change_set: ChangeSet, include_property_values: bool
600
    ) -> DescribeChangeSetOutput:
601
        # TODO: The ChangeSetModelDescriber currently matches AWS behavior by listing
602
        #       resource changes in the order they appear in the template. However, when
603
        #       a resource change is triggered indirectly (e.g., via Ref or GetAtt), the
604
        #       dependency's change appears first in the list.
605
        #       Snapshot tests using the `capture_update_process` fixture rely on a
606
        #       normalizer to account for this ordering. This should be removed in the
607
        #       future by enforcing a consistently correct change ordering at the source.
608
        change_set_describer = ChangeSetModelDescriber(
1✔
609
            change_set=change_set, include_property_values=include_property_values
610
        )
611
        changes: Changes = change_set_describer.get_changes()
1✔
612

613
        result = DescribeChangeSetOutput(
1✔
614
            Status=change_set.status,
615
            ChangeSetId=change_set.change_set_id,
616
            ChangeSetName=change_set.change_set_name,
617
            ExecutionStatus=change_set.execution_status,
618
            RollbackConfiguration=RollbackConfiguration(),
619
            StackId=change_set.stack.stack_id,
620
            StackName=change_set.stack.stack_name,
621
            CreationTime=change_set.creation_time,
622
            Changes=changes,
623
            Capabilities=change_set.stack.capabilities,
624
            StatusReason=change_set.status_reason,
625
            Description=change_set.description,
626
            # TODO: static information
627
            IncludeNestedStacks=False,
628
            NotificationARNs=[],
629
        )
630
        if change_set.resolved_parameters:
1✔
631
            result["Parameters"] = self._render_resolved_parameters(change_set.resolved_parameters)
1✔
632
        return result
1✔
633

634
    @staticmethod
1✔
635
    def _render_resolved_parameters(
1✔
636
        resolved_parameters: dict[str, EngineParameter],
637
    ) -> list[Parameter]:
638
        result = []
1✔
639
        for name, resolved_parameter in resolved_parameters.items():
1✔
640
            parameter = Parameter(
1✔
641
                ParameterKey=name,
642
                ParameterValue=resolved_parameter.get("given_value")
643
                or resolved_parameter.get("default_value"),
644
            )
645
            if resolved_value := resolved_parameter.get("resolved_value"):
1✔
646
                parameter["ResolvedValue"] = resolved_value
1✔
647
            result.append(parameter)
1✔
648

649
        return result
1✔
650

651
    @handler("DescribeChangeSet")
1✔
652
    def describe_change_set(
1✔
653
        self,
654
        context: RequestContext,
655
        change_set_name: ChangeSetNameOrId,
656
        stack_name: StackNameOrId | None = None,
657
        next_token: NextToken | None = None,
658
        include_property_values: IncludePropertyValues | None = None,
659
        **kwargs,
660
    ) -> DescribeChangeSetOutput:
661
        # TODO add support for include_property_values
662
        # only relevant if change_set_name isn't an ARN
663
        state = get_cloudformation_store(context.account_id, context.region)
1✔
664
        change_set = find_change_set_v2(state, change_set_name, stack_name)
1✔
665

666
        if not change_set:
1✔
667
            raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
1✔
668
        result = self._describe_change_set(
1✔
669
            change_set=change_set, include_property_values=include_property_values or False
670
        )
671
        return result
1✔
672

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

686
        change_set.stack.change_set_ids.remove(change_set.change_set_id)
1✔
687
        state.change_sets.pop(change_set.change_set_id)
1✔
688

689
        return DeleteChangeSetOutput()
1✔
690

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

699
        state = get_cloudformation_store(context.account_id, context.region)
1✔
700

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

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

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

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

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

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

736
        template_body = api_utils.extract_template_body(request)
1✔
737
        structured_template = template_preparer.parse_template(template_body)
1✔
738

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

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

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

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

769
        # Create internal change set to execute
770
        change_set = ChangeSet(
1✔
771
            stack,
772
            {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE},
773
            template=after_template,
774
            template_body=template_body,
775
        )
776
        self._setup_change_set_model(
1✔
777
            change_set=change_set,
778
            before_template=None,
779
            after_template=after_template,
780
            before_parameters=None,
781
            after_parameters=after_parameters,
782
            previous_update_model=None,
783
        )
784
        if change_set.status == ChangeSetStatus.FAILED:
1✔
785
            return CreateStackOutput(StackId=stack.stack_id)
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.set_stack_status(StackStatus.CREATE_COMPLETE)
1✔
795
                stack.resolved_resources = result.resources
1✔
796
                stack.resolved_outputs = result.outputs
1✔
797
                # if the deployment succeeded, update the stack's template representation to that
798
                # which was just deployed
799
                stack.template = change_set.template
1✔
800
                stack.template_body = change_set.template_body
1✔
801
                stack.processed_template = change_set.processed_template
1✔
802
                stack.resolved_parameters = change_set.resolved_parameters
1✔
803
                stack.resolved_exports = {}
1✔
804
                for output in result.outputs:
1✔
805
                    if export_name := output.get("ExportName"):
1✔
806
                        stack.resolved_exports[export_name] = output["OutputValue"]
1✔
807
                stack.processed_template = change_set.processed_template
1✔
UNCOV
808
            except Exception as e:
×
UNCOV
809
                LOG.error(
×
810
                    "Create Stack set failed: %s",
811
                    e,
812
                    exc_info=LOG.isEnabledFor(logging.WARNING) and config.CFN_VERBOSE_ERRORS,
813
                )
UNCOV
814
                stack.set_stack_status(StackStatus.CREATE_FAILED)
×
815

816
        start_worker_thread(_run)
1✔
817

818
        return CreateStackOutput(StackId=stack.stack_id)
1✔
819

820
    @handler("CreateStackSet", expand=False)
1✔
821
    def create_stack_set(
1✔
822
        self, context: RequestContext, request: CreateStackSetInput
823
    ) -> CreateStackSetOutput:
824
        state = get_cloudformation_store(context.account_id, context.region)
1✔
825
        stack_set = StackSet(context.account_id, context.region, request)
1✔
826
        state.stack_sets_v2[stack_set.stack_set_id] = stack_set
1✔
827

828
        return CreateStackSetOutput(StackSetId=stack_set.stack_set_id)
1✔
829

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

847
        describe_stack_output: list[ApiStack] = []
1✔
848
        for stack in stacks:
1✔
849
            describe_stack_output.append(self._describe_stack(stack))
1✔
850

851
        return DescribeStacksOutput(Stacks=describe_stack_output)
1✔
852

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

879
        if stack.resolved_parameters:
1✔
880
            stack_description["Parameters"] = self._render_resolved_parameters(
1✔
881
                stack.resolved_parameters
882
            )
883

884
        if stack.resolved_outputs:
1✔
885
            stack_description["Outputs"] = stack.resolved_outputs
1✔
886

887
        return stack_description
1✔
888

889
    @handler("ListStacks")
1✔
890
    def list_stacks(
1✔
891
        self,
892
        context: RequestContext,
893
        next_token: NextToken = None,
894
        stack_status_filter: StackStatusFilter = None,
895
        **kwargs,
896
    ) -> ListStacksOutput:
897
        state = get_cloudformation_store(context.account_id, context.region)
1✔
898

899
        stacks = [
1✔
900
            self._describe_stack(s)
901
            for s in state.stacks_v2.values()
902
            if not stack_status_filter or s.status in stack_status_filter
903
        ]
904

905
        attrs = [
1✔
906
            "StackId",
907
            "StackName",
908
            "TemplateDescription",
909
            "CreationTime",
910
            "LastUpdatedTime",
911
            "DeletionTime",
912
            "StackStatus",
913
            "StackStatusReason",
914
            "ParentId",
915
            "RootId",
916
            "DriftInformation",
917
        ]
918
        stacks = [select_attributes(stack, attrs) for stack in stacks]
1✔
919
        return ListStacksOutput(StackSummaries=stacks)
1✔
920

921
    @handler("ListStackResources")
1✔
922
    def list_stack_resources(
1✔
923
        self, context: RequestContext, stack_name: StackName, next_token: NextToken = None, **kwargs
924
    ) -> ListStackResourcesOutput:
925
        result = self.describe_stack_resources(context, stack_name)
1✔
926

927
        resources = []
1✔
928
        for resource in result.get("StackResources", []):
1✔
929
            resources.append(
1✔
930
                StackResourceSummary(
931
                    LogicalResourceId=resource["LogicalResourceId"],
932
                    PhysicalResourceId=resource["PhysicalResourceId"],
933
                    ResourceType=resource["ResourceType"],
934
                    LastUpdatedTimestamp=resource["Timestamp"],
935
                    ResourceStatus=resource["ResourceStatus"],
936
                    ResourceStatusReason=resource.get("ResourceStatusReason"),
937
                    DriftInformation=resource.get("DriftInformation"),
938
                    ModuleInfo=resource.get("ModuleInfo"),
939
                )
940
            )
941

942
        return ListStackResourcesOutput(StackResourceSummaries=resources)
1✔
943

944
    @handler("DescribeStackResource")
1✔
945
    def describe_stack_resource(
1✔
946
        self,
947
        context: RequestContext,
948
        stack_name: StackName,
949
        logical_resource_id: LogicalResourceId,
950
        **kwargs,
951
    ) -> DescribeStackResourceOutput:
952
        state = get_cloudformation_store(context.account_id, context.region)
1✔
953
        stack = find_stack_v2(state, stack_name)
1✔
954
        if not stack:
1✔
955
            raise StackNotFoundError(
1✔
956
                stack_name, message_override=f"Stack '{stack_name}' does not exist"
957
            )
958

959
        try:
1✔
960
            resource = stack.resolved_resources[logical_resource_id]
1✔
961
        except KeyError:
1✔
962
            raise ValidationError(
1✔
963
                f"Resource {logical_resource_id} does not exist for stack {stack_name}"
964
            )
965

966
        resource_detail = StackResourceDetail(
1✔
967
            StackName=stack.stack_name,
968
            StackId=stack.stack_id,
969
            LogicalResourceId=logical_resource_id,
970
            PhysicalResourceId=resource["PhysicalResourceId"],
971
            ResourceType=resource["Type"],
972
            LastUpdatedTimestamp=resource["LastUpdatedTimestamp"],
973
            ResourceStatus=resource["ResourceStatus"],
974
        )
975
        return DescribeStackResourceOutput(StackResourceDetail=resource_detail)
1✔
976

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

1001
    @handler("CreateStackInstances", expand=False)
1✔
1002
    def create_stack_instances(
1✔
1003
        self,
1004
        context: RequestContext,
1005
        request: CreateStackInstancesInput,
1006
    ) -> CreateStackInstancesOutput:
1007
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1008

1009
        stack_set_name = request["StackSetName"]
1✔
1010
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1011
        if not stack_set:
1✔
1012
            raise StackSetNotFoundError(stack_set_name)
1✔
1013

1014
        op_id = request.get("OperationId") or short_uid()
1✔
1015
        accounts = request["Accounts"]
1✔
1016
        regions = request["Regions"]
1✔
1017

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

1042
                # skip creation of existing stacks
1043
                if find_stack_v2(state, stack_name):
1✔
1044
                    continue
1✔
1045

1046
                result = cf_client.create_stack(StackName=stack_name, **kwargs)
1✔
1047
                # store stack instance
1048
                stack_instance = StackInstance(
1✔
1049
                    account_id=account,
1050
                    region_name=region,
1051
                    stack_set_id=stack_set.stack_set_id,
1052
                    operation_id=op_id,
1053
                    stack_id=result["StackId"],
1054
                )
1055
                stack_set.stack_instances.append(stack_instance)
1✔
1056

1057
                stacks_to_await.append((stack_name, account, region))
1✔
1058

1059
        # wait for completion of stack
1060
        for stack_name, account_id, region_name in stacks_to_await:
1✔
1061
            client = connect_to(
1✔
1062
                aws_access_key_id=account_id, region_name=region_name
1063
            ).cloudformation
1064
            client.get_waiter("stack_create_complete").wait(StackName=stack_name)
1✔
1065

1066
        # record operation
1067
        operation = StackSetOperation(
1✔
1068
            OperationId=op_id,
1069
            StackSetId=stack_set.stack_set_id,
1070
            Action=StackSetOperationAction.CREATE,
1071
            Status=StackSetOperationStatus.SUCCEEDED,
1072
        )
1073
        stack_set.operations[op_id] = operation
1✔
1074

1075
        return CreateStackInstancesOutput(OperationId=op_id)
1✔
1076

1077
    @handler("DescribeStackSetOperation")
1✔
1078
    def describe_stack_set_operation(
1✔
1079
        self,
1080
        context: RequestContext,
1081
        stack_set_name: StackSetName,
1082
        operation_id: ClientRequestToken,
1083
        call_as: CallAs = None,
1084
        **kwargs,
1085
    ) -> DescribeStackSetOperationOutput:
1086
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1087
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1088
        if not stack_set:
1✔
UNCOV
1089
            raise StackSetNotFoundError(stack_set_name)
×
1090

1091
        result = stack_set.operations.get(operation_id)
1✔
1092
        if not result:
1✔
UNCOV
1093
            LOG.debug(
×
1094
                'Unable to find operation ID "%s" for stack set "%s" in list: %s',
1095
                operation_id,
1096
                stack_set_name,
1097
                list(stack_set.operations.keys()),
1098
            )
1099
            # TODO: proper exception
UNCOV
1100
            raise ValueError(
×
1101
                f'Unable to find operation ID "{operation_id}" for stack set "{stack_set_name}"'
1102
            )
1103

1104
        return DescribeStackSetOperationOutput(StackSetOperation=result)
1✔
1105

1106
    @handler("DeleteStackInstances", expand=False)
1✔
1107
    def delete_stack_instances(
1✔
1108
        self,
1109
        context: RequestContext,
1110
        request: DeleteStackInstancesInput,
1111
    ) -> DeleteStackInstancesOutput:
1112
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1113

1114
        stack_set_name = request["StackSetName"]
1✔
1115
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1116
        if not stack_set:
1✔
UNCOV
1117
            raise StackSetNotFoundError(stack_set_name)
×
1118

1119
        op_id = request.get("OperationId") or short_uid()
1✔
1120

1121
        accounts = request["Accounts"]
1✔
1122
        regions = request["Regions"]
1✔
1123

1124
        operations_to_await = []
1✔
1125
        for account in accounts:
1✔
1126
            for region in regions:
1✔
1127
                cf_client = connect_to(aws_access_key_id=account, region_name=region).cloudformation
1✔
1128
                instance = find_stack_instance(stack_set, account, region)
1✔
1129

1130
                # TODO: check parity with AWS
1131
                # TODO: delete stack instance?
1132
                if not instance:
1✔
UNCOV
1133
                    continue
×
1134

1135
                cf_client.delete_stack(StackName=instance.stack_id)
1✔
1136
                operations_to_await.append(instance)
1✔
1137

1138
        for instance in operations_to_await:
1✔
1139
            cf_client = connect_to(
1✔
1140
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1141
            ).cloudformation
1142
            cf_client.get_waiter("stack_delete_complete").wait(StackName=instance.stack_id)
1✔
1143
            stack_set.stack_instances.remove(instance)
1✔
1144

1145
        # record operation
1146
        operation = StackSetOperation(
1✔
1147
            OperationId=op_id,
1148
            StackSetId=stack_set.stack_set_id,
1149
            Action=StackSetOperationAction.DELETE,
1150
            Status=StackSetOperationStatus.SUCCEEDED,
1151
        )
1152
        stack_set.operations[op_id] = operation
1✔
1153

1154
        return DeleteStackInstancesOutput(OperationId=op_id)
1✔
1155

1156
    @handler("DeleteStackSet")
1✔
1157
    def delete_stack_set(
1✔
1158
        self,
1159
        context: RequestContext,
1160
        stack_set_name: StackSetName,
1161
        call_as: CallAs = None,
1162
        **kwargs,
1163
    ) -> DeleteStackSetOutput:
1164
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1165
        stack_set = find_stack_set_v2(state, stack_set_name)
1✔
1166
        if not stack_set:
1✔
1167
            # operation is idempotent
1168
            return DeleteStackSetOutput()
1✔
1169

1170
        # clean up any left-over instances
1171
        operations_to_await = []
1✔
1172
        for instance in stack_set.stack_instances:
1✔
UNCOV
1173
            cf_client = connect_to(
×
1174
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1175
            ).cloudformation
UNCOV
1176
            cf_client.delete_stack(StackName=instance.stack_id)
×
UNCOV
1177
            operations_to_await.append(instance)
×
1178

1179
        for instance in operations_to_await:
1✔
UNCOV
1180
            cf_client = connect_to(
×
1181
                aws_access_key_id=instance.account_id, region_name=instance.region_name
1182
            ).cloudformation
UNCOV
1183
            cf_client.get_waiter("stack_delete_complete").wait(StackName=instance.stack_id)
×
UNCOV
1184
            stack_set.stack_instances.remove(instance)
×
1185

1186
        state.stack_sets_v2.pop(stack_set.stack_set_id)
1✔
1187

1188
        return DeleteStackSetOutput()
1✔
1189

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

1208
    @handler("GetTemplate")
1✔
1209
    def get_template(
1✔
1210
        self,
1211
        context: RequestContext,
1212
        stack_name: StackName = None,
1213
        change_set_name: ChangeSetNameOrId = None,
1214
        template_stage: TemplateStage = None,
1215
        **kwargs,
1216
    ) -> GetTemplateOutput:
1217
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1218
        if change_set_name:
1✔
UNCOV
1219
            change_set = find_change_set_v2(state, change_set_name, stack_name=stack_name)
×
UNCOV
1220
            stack = change_set.stack
×
1221
        elif stack_name:
1✔
1222
            stack = find_stack_v2(state, stack_name)
1✔
1223
        else:
UNCOV
1224
            raise StackNotFoundError(stack_name)
×
1225

1226
        if template_stage == TemplateStage.Processed and "Transform" in stack.template_body:
1✔
1227
            template_body = json.dumps(stack.processed_template)
1✔
1228
        else:
1229
            template_body = stack.template_body
1✔
1230

1231
        return GetTemplateOutput(
1✔
1232
            TemplateBody=template_body,
1233
            StagesAvailable=[TemplateStage.Original, TemplateStage.Processed],
1234
        )
1235

1236
    @handler("GetTemplateSummary", expand=False)
1✔
1237
    def get_template_summary(
1✔
1238
        self,
1239
        context: RequestContext,
1240
        request: GetTemplateSummaryInput,
1241
    ) -> GetTemplateSummaryOutput:
1242
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1243
        stack_name = request.get("StackName")
1✔
1244

1245
        if stack_name:
1✔
1246
            stack = find_stack_v2(state, stack_name)
1✔
1247
            if not stack:
1✔
UNCOV
1248
                raise StackNotFoundError(stack_name)
×
1249
            template = stack.template
1✔
1250
        else:
1251
            template_body = request.get("TemplateBody")
1✔
1252
            # s3 or secretsmanager url
1253
            template_url = request.get("TemplateURL")
1✔
1254

1255
            # validate and resolve template
1256
            if template_body and template_url:
1✔
UNCOV
1257
                raise ValidationError(
×
1258
                    "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1259
                )  # TODO: check proper message
1260

1261
            if not template_body and not template_url:
1✔
UNCOV
1262
                raise ValidationError(
×
1263
                    "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1264
                )  # TODO: check proper message
1265

1266
            template_body = api_utils.extract_template_body(request)
1✔
1267
            template = template_preparer.parse_template(template_body)
1✔
1268

1269
        id_summaries = defaultdict(list)
1✔
1270
        for resource_id, resource in template["Resources"].items():
1✔
1271
            res_type = resource["Type"]
1✔
1272
            id_summaries[res_type].append(resource_id)
1✔
1273

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

1295
        return result
1✔
1296

1297
    @handler("UpdateTerminationProtection")
1✔
1298
    def update_termination_protection(
1✔
1299
        self,
1300
        context: RequestContext,
1301
        enable_termination_protection: EnableTerminationProtection,
1302
        stack_name: StackNameOrId,
1303
        **kwargs,
1304
    ) -> UpdateTerminationProtectionOutput:
1305
        state = get_cloudformation_store(context.account_id, context.region)
1✔
1306
        stack = find_stack_v2(state, stack_name)
1✔
1307
        if not stack:
1✔
1308
            raise StackNotFoundError(stack_name)
×
1309

1310
        stack.enable_termination_protection = enable_termination_protection
1✔
1311
        return UpdateTerminationProtectionOutput(StackId=stack.stack_id)
1✔
1312

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

1329
        # validate and resolve template
1330
        if template_body and template_url:
1✔
1331
            raise ValidationError(
×
1332
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1333
            )  # TODO: check proper message
1334

1335
        if not template_body and not template_url:
1✔
UNCOV
1336
            raise ValidationError(
×
1337
                "Specify exactly one of 'TemplateBody' or 'TemplateUrl'"
1338
            )  # TODO: check proper message
1339

1340
        template_body = api_utils.extract_template_body(request)
1✔
1341
        structured_template = template_preparer.parse_template(template_body)
1✔
1342

1343
        if "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", []) and (
1✔
1344
            "Transform" in structured_template.keys() or "Fn::Transform" in template_body
1345
        ):
UNCOV
1346
            raise InsufficientCapabilitiesException(
×
1347
                "Requires capabilities : [CAPABILITY_AUTO_EXPAND]"
1348
            )
1349

1350
        # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing
1351
        # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet)
1352
        stack: Stack
1353
        if is_stack_arn(stack_name):
1✔
UNCOV
1354
            stack = state.stacks_v2.get(stack_name)
×
UNCOV
1355
            if not stack:
×
UNCOV
1356
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
1357

1358
        else:
1359
            # stack name specified, so fetch the stack by name
1360
            stack_candidates: list[Stack] = [
1✔
1361
                s for stack_arn, s in state.stacks_v2.items() if s.stack_name == stack_name
1362
            ]
1363
            active_stack_candidates = [
1✔
1364
                s for s in stack_candidates if self._stack_status_is_active(s.status)
1365
            ]
1366

1367
            if not active_stack_candidates:
1✔
UNCOV
1368
                raise ValidationError(f"Stack '{stack_name}' does not exist.")
×
1369
            elif len(active_stack_candidates) > 1:
1✔
UNCOV
1370
                raise RuntimeError("Multiple stacks matched, update matching logic")
×
1371
            stack = active_stack_candidates[0]
1✔
1372

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

1383
        before_template = stack.template
1✔
1384
        after_template = structured_template
1✔
1385

1386
        previous_update_model = None
1✔
1387
        if stack.change_set_id:
1✔
1388
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
1389
                previous_update_model = previous_change_set.update_model
1✔
1390

1391
        change_set = ChangeSet(
1✔
1392
            stack,
1393
            {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE},
1394
            template_body=template_body,
1395
            template=after_template,
1396
        )
1397
        self._setup_change_set_model(
1✔
1398
            change_set=change_set,
1399
            before_template=before_template,
1400
            after_template=after_template,
1401
            before_parameters=before_parameters,
1402
            after_parameters=after_parameters,
1403
            previous_update_model=previous_update_model,
1404
        )
1405

1406
        # TODO: some changes are only detectable at runtime; consider using
1407
        #       the ChangeSetModelDescriber, or a new custom visitors, to
1408
        #       pick-up on runtime changes.
1409
        if change_set.update_model.node_template.change_type == ChangeType.UNCHANGED:
1✔
1410
            raise ValidationError("No updates are to be performed.")
1✔
1411

1412
        stack.set_stack_status(StackStatus.UPDATE_IN_PROGRESS)
1✔
1413
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
1414

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

1438
        start_worker_thread(_run)
1✔
1439

1440
        return UpdateStackOutput(StackId=stack.stack_id)
1✔
1441

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

1460
            if "ParameterValue" in parameter:
1✔
1461
                after_parameters[key] = parameter["ParameterValue"]
1✔
1462
                continue
1✔
1463
        return after_parameters
1✔
1464

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

1482
        # shortcut for stacks which have no deployed resources i.e. where a change set was
1483
        # created, but never executed
1484
        if stack.status == StackStatus.REVIEW_IN_PROGRESS and not stack.resolved_resources:
1✔
1485
            stack.set_stack_status(StackStatus.DELETE_COMPLETE)
1✔
1486
            stack.deletion_time = datetime.now(tz=UTC)
1✔
1487
            return
1✔
1488

1489
        previous_update_model = None
1✔
1490
        if stack.change_set_id:
1✔
1491
            if previous_change_set := find_change_set_v2(state, stack.change_set_id):
1✔
1492
                previous_update_model = previous_change_set.update_model
1✔
1493

1494
        # create a dummy change set
1495
        change_set = ChangeSet(
1✔
1496
            stack, {"ChangeSetName": f"delete-stack_{stack.stack_name}"}, template_body=""
1497
        )  # noqa
1498
        self._setup_change_set_model(
1✔
1499
            change_set=change_set,
1500
            before_template=stack.template,
1501
            after_template=None,
1502
            before_parameters=stack.resolved_parameters,
1503
            after_parameters=None,
1504
            previous_update_model=previous_update_model,
1505
        )
1506

1507
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
1508

1509
        def _run(*args):
1✔
1510
            try:
1✔
1511
                stack.set_stack_status(StackStatus.DELETE_IN_PROGRESS)
1✔
1512
                change_set_executor.execute()
1✔
1513
                stack.set_stack_status(StackStatus.DELETE_COMPLETE)
1✔
1514
                stack.deletion_time = datetime.now(tz=UTC)
1✔
UNCOV
1515
            except Exception as e:
×
UNCOV
1516
                LOG.warning(
×
1517
                    "Failed to delete stack '%s': %s",
1518
                    stack.stack_name,
1519
                    e,
1520
                    exc_info=LOG.isEnabledFor(logging.DEBUG) and config.CFN_VERBOSE_ERRORS,
1521
                )
UNCOV
1522
                stack.set_stack_status(StackStatus.DELETE_FAILED)
×
1523

1524
        start_worker_thread(_run)
1✔
1525

1526
    @handler("ListExports")
1✔
1527
    def list_exports(
1✔
1528
        self, context: RequestContext, next_token: NextToken = None, **kwargs
1529
    ) -> ListExportsOutput:
1530
        store = get_cloudformation_store(account_id=context.account_id, region_name=context.region)
1✔
1531
        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