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

localstack / localstack / 17144436094

21 Aug 2025 11:28PM UTC coverage: 86.843% (-0.03%) from 86.876%
17144436094

push

github

web-flow
APIGW: internalize DeleteIntegrationResponse (#13046)

40 of 45 new or added lines in 1 file covered. (88.89%)

235 existing lines in 11 files now uncovered.

67068 of 77229 relevant lines covered (86.84%)

0.87 hits per line

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

89.85
/localstack-core/localstack/services/cloudformation/v2/provider.py
1
import copy
1✔
2
import json
1✔
3
import logging
1✔
4
import re
1✔
5
from collections import defaultdict
1✔
6
from datetime import UTC, datetime
1✔
7

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

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

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

131

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

135

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

139

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

143

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

154

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

159

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

178

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

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

199

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

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

208
    return None
1✔
209

210

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

217

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

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

264
            resolved_parameters[name] = resolved_parameter
1✔
265

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

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

279
        return resolved_parameters
1✔
280

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

300
        change_set.resolved_parameters = resolved_parameters
1✔
301

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

596
        start_worker_thread(_run)
1✔
597

598
        return ExecuteChangeSetOutput()
1✔
599

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

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

619
        return result
1✔
620

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

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

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

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

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

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

688
        return DeleteChangeSetOutput()
1✔
689

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

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

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

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

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

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

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

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

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

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

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

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

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

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

786
        stack.processed_template = change_set.processed_template
1✔
787

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

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

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

823
        start_worker_thread(_run)
1✔
824

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

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

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

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

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

858
        return DescribeStacksOutput(Stacks=describe_stack_output)
1✔
859

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

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

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

895
        return stack_description
1✔
896

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

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

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

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

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

950
        return ListStackResourcesOutput(StackResourceSummaries=resources)
1✔
951

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

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

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

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

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

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

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

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

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

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

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

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

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

1083
        return CreateStackInstancesOutput(OperationId=op_id)
1✔
1084

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

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

1112
        return DescribeStackSetOperationOutput(StackSetOperation=result)
1✔
1113

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

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

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

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

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

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

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

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

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

1162
        return DeleteStackInstancesOutput(OperationId=op_id)
1✔
1163

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

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

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

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

1196
        return DeleteStackSetOutput()
1✔
1197

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

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

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

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

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

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

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

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

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

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

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

1303
        return result
1✔
1304

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1446
        start_worker_thread(_run)
1✔
1447

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

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

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

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

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

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

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

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

1517
        change_set_executor = ChangeSetModelExecutor(change_set)
1✔
1518

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

1533
        start_worker_thread(_run)
1✔
1534

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