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

localstack / localstack / 20565403496

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

Pull #13567

github

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

67166 of 79862 relevant lines covered (84.1%)

0.84 hits per line

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

19.03
/localstack-core/localstack/services/cloudformation/engine/template_deployer.py
1
import base64
1✔
2
import json
1✔
3
import logging
1✔
4
import re
1✔
5
import traceback
1✔
6
import uuid
1✔
7

8
from botocore.exceptions import ClientError
1✔
9

10
from localstack import config
1✔
11
from localstack.aws.connect import connect_to
1✔
12
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
1✔
13
from localstack.services.cloudformation.analytics import track_resource_operation
1✔
14
from localstack.services.cloudformation.deployment_utils import (
1✔
15
    PLACEHOLDER_AWS_NO_VALUE,
16
    get_action_name_for_resource_change,
17
    log_not_available_message,
18
    remove_none_values,
19
)
20
from localstack.services.cloudformation.engine.changes import ChangeConfig, ResourceChange
1✔
21
from localstack.services.cloudformation.engine.entities import Stack, StackChangeSet
1✔
22
from localstack.services.cloudformation.engine.parameters import StackParameter
1✔
23
from localstack.services.cloudformation.engine.quirks import VALID_GETATT_PROPERTIES
1✔
24
from localstack.services.cloudformation.engine.resource_ordering import (
1✔
25
    order_changes,
26
    order_resources,
27
)
28
from localstack.services.cloudformation.engine.template_utils import (
1✔
29
    AWS_URL_SUFFIX,
30
    fn_equals_type_conversion,
31
    get_deps_for_resource,
32
)
33
from localstack.services.cloudformation.resource_provider import (
1✔
34
    Credentials,
35
    NoResourceProvider,
36
    OperationStatus,
37
    ProgressEvent,
38
    ResourceProviderExecutor,
39
    ResourceProviderPayload,
40
    get_resource_type,
41
)
42
from localstack.services.cloudformation.service_models import (
1✔
43
    DependencyNotYetSatisfied,
44
)
45
from localstack.services.cloudformation.stores import exports_map, find_stack
1✔
46
from localstack.utils.aws.arns import get_partition
1✔
47
from localstack.utils.functions import prevent_stack_overflow
1✔
48
from localstack.utils.json import clone_safe
1✔
49
from localstack.utils.strings import to_bytes, to_str
1✔
50
from localstack.utils.threads import start_worker_thread
1✔
51

52
from localstack.services.cloudformation.models import *  # noqa: F401, F403, isort:skip
1✔
53
from localstack.utils.urls import localstack_host
1✔
54

55
ACTION_CREATE = "create"
1✔
56
ACTION_DELETE = "delete"
1✔
57

58
REGEX_OUTPUT_APIGATEWAY = re.compile(
1✔
59
    rf"^(https?://.+\.execute-api\.)(?:[^-]+-){{2,3}}\d\.(amazonaws\.com|{AWS_URL_SUFFIX})/?(.*)$"
60
)
61
REGEX_DYNAMIC_REF = re.compile("{{resolve:([^:]+):(.+)}}")
1✔
62

63
LOG = logging.getLogger(__name__)
1✔
64

65
# list of static attribute references to be replaced in {'Fn::Sub': '...'} strings
66
STATIC_REFS = ["AWS::Region", "AWS::Partition", "AWS::StackName", "AWS::AccountId"]
1✔
67

68
# Mock value for unsupported type references
69
MOCK_REFERENCE = "unknown"
1✔
70

71

72
class NoStackUpdates(Exception):
1✔
73
    """Exception indicating that no actions are to be performed in a stack update (which is not allowed)"""
74

75
    pass
1✔
76

77

78
# ---------------------
79
# CF TEMPLATE HANDLING
80
# ---------------------
81

82

83
def get_attr_from_model_instance(
1✔
84
    resource: dict,
85
    attribute_name: str,
86
    resource_type: str,
87
    resource_id: str,
88
    attribute_sub_name: str | None = None,
89
) -> str:
90
    if resource["PhysicalResourceId"] == MOCK_REFERENCE:
×
91
        LOG.warning(
×
92
            "Attribute '%s' requested from unsupported resource with id %s",
93
            attribute_name,
94
            resource_id,
95
        )
96
        return MOCK_REFERENCE
×
97

98
    properties = resource.get("Properties", {})
×
99
    # if there's no entry in VALID_GETATT_PROPERTIES for the resource type we still default to "open" and accept anything
100
    valid_atts = VALID_GETATT_PROPERTIES.get(resource_type)
×
101
    if valid_atts is not None and attribute_name not in valid_atts:
×
102
        LOG.warning(
×
103
            "Invalid attribute in Fn::GetAtt for %s:  | %s.%s",
104
            resource_type,
105
            resource_id,
106
            attribute_name,
107
        )
108
        raise Exception(
×
109
            f"Resource type {resource_type} does not support attribute {{{attribute_name}}}"
110
        )  # TODO: check CFn behavior via snapshot
111

112
    attribute_candidate = properties.get(attribute_name)
×
113
    if attribute_sub_name:
×
114
        return attribute_candidate.get(attribute_sub_name)
×
115
    if "." in attribute_name:
×
116
        # was used for legacy, but keeping it since it might have to work for a custom resource as well
117
        if attribute_candidate:
×
118
            return attribute_candidate
×
119

120
        # some resources (e.g. ElastiCache) have their readOnly attributes defined as Aa.Bb but the property is named AaBb
121
        if attribute_candidate := properties.get(attribute_name.replace(".", "")):
×
122
            return attribute_candidate
×
123

124
        # accessing nested properties
125
        parts = attribute_name.split(".")
×
126
        attribute = properties
×
127
        # TODO: the attribute fetching below is a temporary workaround for the dependency resolution.
128
        #  It is caused by trying to access the resource attribute that has not been deployed yet.
129
        #  This should be a hard error.“
130
        for part in parts:
×
131
            if attribute is None:
×
132
                return None
×
133
            attribute = attribute.get(part)
×
134
        return attribute
×
135

136
    # If we couldn't find the attribute, this is actually an irrecoverable error.
137
    # After the resource has a state of CREATE_COMPLETE, all attributes should already be set.
138
    # TODO: raise here instead
139
    # if attribute_candidate is None:
140
    # raise Exception(
141
    #     f"Failed to resolve attribute for Fn::GetAtt in {resource_type}: {resource_id}.{attribute_name}"
142
    # )  # TODO: check CFn behavior via snapshot
143
    return attribute_candidate
×
144

145

146
def resolve_ref(
1✔
147
    account_id: str,
148
    region_name: str,
149
    stack_name: str,
150
    resources: dict,
151
    parameters: dict[str, StackParameter],
152
    ref: str,
153
):
154
    """
155
    ref always needs to be a static string
156
    ref can be one of these:
157
    1. a pseudo-parameter (e.g. AWS::Region)
158
    2. a parameter
159
    3. the id of a resource (PhysicalResourceId
160
    """
161
    # pseudo parameter
162
    if ref == "AWS::Region":
1✔
163
        return region_name
×
164
    if ref == "AWS::Partition":
1✔
165
        return get_partition(region_name)
×
166
    if ref == "AWS::StackName":
1✔
167
        return stack_name
×
168
    if ref == "AWS::StackId":
1✔
169
        stack = find_stack(account_id, region_name, stack_name)
×
170
        if not stack:
×
171
            raise ValueError(f"No stack {stack_name} found")
×
172
        return stack.stack_id
×
173
    if ref == "AWS::AccountId":
1✔
174
        return account_id
×
175
    if ref == "AWS::NoValue":
1✔
176
        return PLACEHOLDER_AWS_NO_VALUE
×
177
    if ref == "AWS::NotificationARNs":
1✔
178
        # TODO!
179
        return {}
×
180
    if ref == "AWS::URLSuffix":
1✔
181
        return AWS_URL_SUFFIX
×
182

183
    # parameter
184
    if parameter := parameters.get(ref):
1✔
185
        parameter_type: str = parameter["ParameterType"]
1✔
186
        parameter_value = parameter.get("ResolvedValue") or parameter.get("ParameterValue")
1✔
187

188
        if "CommaDelimitedList" in parameter_type or parameter_type.startswith("List<"):
1✔
189
            return [p.strip() for p in parameter_value.split(",")]
1✔
190
        else:
191
            return parameter_value
1✔
192

193
    # resource
194
    resource = resources.get(ref)
×
195
    if not resource:
×
196
        raise Exception(
×
197
            f"Resource target for `Ref {ref}` could not be found. Is there a resource with name {ref} in your stack?"
198
        )
199

200
    return resources[ref].get("PhysicalResourceId")
×
201

202

203
# Using a @prevent_stack_overflow decorator here to avoid infinite recursion
204
# in case we load stack exports that have circular dependencies (see issue 3438)
205
# TODO: Potentially think about a better approach in the future
206
@prevent_stack_overflow(match_parameters=True)
1✔
207
def resolve_refs_recursively(
1✔
208
    account_id: str,
209
    region_name: str,
210
    stack_name: str,
211
    resources: dict,
212
    mappings: dict,
213
    conditions: dict[str, bool],
214
    parameters: dict,
215
    value,
216
):
217
    result = _resolve_refs_recursively(
1✔
218
        account_id, region_name, stack_name, resources, mappings, conditions, parameters, value
219
    )
220

221
    # localstack specific patches
222
    if isinstance(result, str):
1✔
223
        # we're trying to filter constructed API urls here (e.g. via Join in the template)
224
        api_match = REGEX_OUTPUT_APIGATEWAY.match(result)
1✔
225
        if api_match and result in config.CFN_STRING_REPLACEMENT_DENY_LIST:
1✔
226
            return result
×
227
        elif api_match:
1✔
228
            prefix = api_match[1]
×
229
            host = api_match[2]
×
230
            path = api_match[3]
×
231
            port = localstack_host().port
×
232
            return f"{prefix}{host}:{port}/{path}"
×
233

234
        # basic dynamic reference support
235
        # see: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references.html
236
        # technically there are more restrictions for each of these services but checking each of these
237
        # isn't really necessary for the current level of emulation
238
        dynamic_ref_match = REGEX_DYNAMIC_REF.match(result)
1✔
239
        if dynamic_ref_match:
1✔
240
            service_name = dynamic_ref_match[1]
×
241
            reference_key = dynamic_ref_match[2]
×
242

243
            # only these 3 services are supported for dynamic references right now
244
            if service_name == "ssm":
×
245
                ssm_client = connect_to(aws_access_key_id=account_id, region_name=region_name).ssm
×
246
                try:
×
247
                    return ssm_client.get_parameter(Name=reference_key)["Parameter"]["Value"]
×
248
                except ClientError as e:
×
249
                    LOG.error("client error accessing SSM parameter '%s': %s", reference_key, e)
×
250
                    raise
×
251
            elif service_name == "ssm-secure":
×
252
                ssm_client = connect_to(aws_access_key_id=account_id, region_name=region_name).ssm
×
253
                try:
×
254
                    return ssm_client.get_parameter(Name=reference_key, WithDecryption=True)[
×
255
                        "Parameter"
256
                    ]["Value"]
257
                except ClientError as e:
×
258
                    LOG.error("client error accessing SSM parameter '%s': %s", reference_key, e)
×
259
                    raise
×
260
            elif service_name == "secretsmanager":
×
261
                # reference key needs to be parsed further
262
                # because {{resolve:secretsmanager:secret-id:secret-string:json-key:version-stage:version-id}}
263
                # we match for "secret-id:secret-string:json-key:version-stage:version-id"
264
                # where
265
                #   secret-id can either be the secret name or the full ARN of the secret
266
                #   secret-string *must* be SecretString
267
                #   all other values are optional
268
                secret_id = reference_key
×
269
                [json_key, version_stage, version_id] = [None, None, None]
×
270
                if "SecretString" in reference_key:
×
271
                    parts = reference_key.split(":SecretString:")
×
272
                    secret_id = parts[0]
×
273
                    # json-key, version-stage and version-id are optional.
274
                    [json_key, version_stage, version_id] = f"{parts[1]}::".split(":")[:3]
×
275

276
                kwargs = {}  # optional args for get_secret_value
×
277
                if version_id:
×
278
                    kwargs["VersionId"] = version_id
×
279
                if version_stage:
×
280
                    kwargs["VersionStage"] = version_stage
×
281

282
                secretsmanager_client = connect_to(
×
283
                    aws_access_key_id=account_id, region_name=region_name
284
                ).secretsmanager
285
                try:
×
286
                    secret_value = secretsmanager_client.get_secret_value(
×
287
                        SecretId=secret_id, **kwargs
288
                    )["SecretString"]
289
                except ClientError:
×
290
                    LOG.error("client error while trying to access key '%s': %s", secret_id)
×
291
                    raise
×
292

293
                if json_key:
×
294
                    json_secret = json.loads(secret_value)
×
295
                    if json_key not in json_secret:
×
296
                        raise DependencyNotYetSatisfied(
×
297
                            resource_ids=secret_id,
298
                            message=f"Key {json_key} is not yet available in secret {secret_id}.",
299
                        )
300
                    return json_secret[json_key]
×
301
                else:
302
                    return secret_value
×
303
            else:
304
                LOG.warning(
×
305
                    "Unsupported service for dynamic parameter: service_name=%s", service_name
306
                )
307

308
    return result
1✔
309

310

311
@prevent_stack_overflow(match_parameters=True)
1✔
312
def _resolve_refs_recursively(
1✔
313
    account_id: str,
314
    region_name: str,
315
    stack_name: str,
316
    resources: dict,
317
    mappings: dict,
318
    conditions: dict,
319
    parameters: dict,
320
    value: dict | list | str | bytes | None,
321
):
322
    if isinstance(value, dict):
1✔
323
        keys_list = list(value.keys())
1✔
324
        stripped_fn_lower = keys_list[0].lower().split("::")[-1] if len(keys_list) == 1 else None
1✔
325

326
        # process special operators
327
        if keys_list == ["Ref"]:
1✔
328
            ref = resolve_ref(
1✔
329
                account_id, region_name, stack_name, resources, parameters, value["Ref"]
330
            )
331
            if ref is None:
1✔
332
                msg = 'Unable to resolve Ref for resource "{}" (yet)'.format(value["Ref"])
×
333
                LOG.debug("%s - %s", msg, resources.get(value["Ref"]) or set(resources.keys()))
×
334

335
                raise DependencyNotYetSatisfied(resource_ids=value["Ref"], message=msg)
×
336

337
            ref = resolve_refs_recursively(
1✔
338
                account_id,
339
                region_name,
340
                stack_name,
341
                resources,
342
                mappings,
343
                conditions,
344
                parameters,
345
                ref,
346
            )
347
            return ref
1✔
348

349
        if stripped_fn_lower == "getatt":
1✔
350
            attr_ref = value[keys_list[0]]
×
351
            attr_ref = attr_ref.split(".") if isinstance(attr_ref, str) else attr_ref
×
352
            resource_logical_id = attr_ref[0]
×
353
            attribute_name = attr_ref[1]
×
354
            attribute_sub_name = attr_ref[2] if len(attr_ref) > 2 else None
×
355

356
            # the attribute name can be a Ref
357
            attribute_name = resolve_refs_recursively(
×
358
                account_id,
359
                region_name,
360
                stack_name,
361
                resources,
362
                mappings,
363
                conditions,
364
                parameters,
365
                attribute_name,
366
            )
367
            resource = resources.get(resource_logical_id)
×
368

369
            resource_type = get_resource_type(resource)
×
370
            resolved_getatt = get_attr_from_model_instance(
×
371
                resource,
372
                attribute_name,
373
                resource_type,
374
                resource_logical_id,
375
                attribute_sub_name,
376
            )
377

378
            # TODO: we should check the deployment state and not try to GetAtt from a resource that is still IN_PROGRESS or hasn't started yet.
379
            if resolved_getatt is None:
×
380
                raise DependencyNotYetSatisfied(
×
381
                    resource_ids=resource_logical_id,
382
                    message=f"Could not resolve attribute '{attribute_name}' on resource '{resource_logical_id}'",
383
                )
384

385
            return resolved_getatt
×
386

387
        if stripped_fn_lower == "join":
1✔
388
            join_values = value[keys_list[0]][1]
×
389

390
            # this can actually be another ref that produces a list as output
391
            if isinstance(join_values, dict):
×
392
                join_values = resolve_refs_recursively(
×
393
                    account_id,
394
                    region_name,
395
                    stack_name,
396
                    resources,
397
                    mappings,
398
                    conditions,
399
                    parameters,
400
                    join_values,
401
                )
402

403
            # resolve reference in the items list
404
            assert isinstance(join_values, list)
×
405
            join_values = resolve_refs_recursively(
×
406
                account_id,
407
                region_name,
408
                stack_name,
409
                resources,
410
                mappings,
411
                conditions,
412
                parameters,
413
                join_values,
414
            )
415

416
            none_values = [v for v in join_values if v is None]
×
417
            if none_values:
×
418
                LOG.warning(
×
419
                    "Cannot resolve Fn::Join '%s' due to null values: '%s'", value, join_values
420
                )
421
                raise Exception(
×
422
                    f"Cannot resolve CF Fn::Join {value} due to null values: {join_values}"
423
                )
424
            return value[keys_list[0]][0].join([str(v) for v in join_values])
×
425

426
        if stripped_fn_lower == "sub":
1✔
427
            item_to_sub = value[keys_list[0]]
×
428

429
            attr_refs = {r: {"Ref": r} for r in STATIC_REFS}
×
430
            if not isinstance(item_to_sub, list):
×
431
                item_to_sub = [item_to_sub, {}]
×
432
            result = item_to_sub[0]
×
433
            item_to_sub[1].update(attr_refs)
×
434

435
            for key, val in item_to_sub[1].items():
×
436
                resolved_val = resolve_refs_recursively(
×
437
                    account_id,
438
                    region_name,
439
                    stack_name,
440
                    resources,
441
                    mappings,
442
                    conditions,
443
                    parameters,
444
                    val,
445
                )
446

447
                if isinstance(resolved_val, (list, dict, tuple)):
×
448
                    # We don't have access to the resource that's a dependency in this case,
449
                    # so do the best we can with the resource ids
450
                    raise DependencyNotYetSatisfied(
×
451
                        resource_ids=key, message=f"Could not resolve {val} to terminal value type"
452
                    )
453
                result = result.replace(f"${{{key}}}", str(resolved_val))
×
454

455
            # resolve placeholders
456
            result = resolve_placeholders_in_string(
×
457
                account_id,
458
                region_name,
459
                result,
460
                stack_name,
461
                resources,
462
                mappings,
463
                conditions,
464
                parameters,
465
            )
466
            return result
×
467

468
        if stripped_fn_lower == "findinmap":
1✔
469
            # "Fn::FindInMap"
470
            mapping_id = value[keys_list[0]][0]
×
471

472
            if isinstance(mapping_id, dict) and "Ref" in mapping_id:
×
473
                # TODO: ??
474
                mapping_id = resolve_ref(
×
475
                    account_id, region_name, stack_name, resources, parameters, mapping_id["Ref"]
476
                )
477

478
            selected_map = mappings.get(mapping_id)
×
479
            if not selected_map:
×
480
                raise Exception(
×
481
                    f"Cannot find Mapping with ID {mapping_id} for Fn::FindInMap: {value[keys_list[0]]} {list(resources.keys())}"
482
                    # TODO: verify
483
                )
484

485
            first_level_attribute = value[keys_list[0]][1]
×
486
            first_level_attribute = resolve_refs_recursively(
×
487
                account_id,
488
                region_name,
489
                stack_name,
490
                resources,
491
                mappings,
492
                conditions,
493
                parameters,
494
                first_level_attribute,
495
            )
496

497
            if first_level_attribute not in selected_map:
×
498
                raise Exception(
×
499
                    f"Cannot find map key '{first_level_attribute}' in mapping '{mapping_id}'"
500
                )
501
            first_level_mapping = selected_map[first_level_attribute]
×
502

503
            second_level_attribute = value[keys_list[0]][2]
×
504
            if not isinstance(second_level_attribute, str):
×
505
                second_level_attribute = resolve_refs_recursively(
×
506
                    account_id,
507
                    region_name,
508
                    stack_name,
509
                    resources,
510
                    mappings,
511
                    conditions,
512
                    parameters,
513
                    second_level_attribute,
514
                )
515
            if second_level_attribute not in first_level_mapping:
×
516
                raise Exception(
×
517
                    f"Cannot find map key '{second_level_attribute}' in mapping '{mapping_id}' under key '{first_level_attribute}'"
518
                )
519

520
            return first_level_mapping[second_level_attribute]
×
521

522
        if stripped_fn_lower == "importvalue":
1✔
523
            import_value_key = resolve_refs_recursively(
×
524
                account_id,
525
                region_name,
526
                stack_name,
527
                resources,
528
                mappings,
529
                conditions,
530
                parameters,
531
                value[keys_list[0]],
532
            )
533
            exports = exports_map(account_id, region_name)
×
534
            stack_export = exports.get(import_value_key) or {}
×
535
            if not stack_export.get("Value"):
×
536
                LOG.info(
×
537
                    'Unable to find export "%s" in stack "%s", existing export names: %s',
538
                    import_value_key,
539
                    stack_name,
540
                    list(exports.keys()),
541
                )
542
                return None
×
543
            return stack_export["Value"]
×
544

545
        if stripped_fn_lower == "if":
1✔
546
            condition, option1, option2 = value[keys_list[0]]
×
547
            condition = conditions.get(condition)
×
548
            if condition is None:
×
549
                LOG.warning(
×
550
                    "Cannot find condition '%s' in conditions mapping: '%s'",
551
                    condition,
552
                    conditions.keys(),
553
                )
554
                raise KeyError(
×
555
                    f"Cannot find condition '{condition}' in conditions mapping: '{conditions.keys()}'"
556
                )
557

558
            result = resolve_refs_recursively(
×
559
                account_id,
560
                region_name,
561
                stack_name,
562
                resources,
563
                mappings,
564
                conditions,
565
                parameters,
566
                option1 if condition else option2,
567
            )
568
            return result
×
569

570
        if stripped_fn_lower == "condition":
1✔
571
            # FIXME: this should only allow strings, no evaluation should be performed here
572
            #   see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-condition.html
573
            key = value[keys_list[0]]
×
574
            result = conditions.get(key)
×
575
            if result is None:
×
576
                LOG.warning("Cannot find key '%s' in conditions: '%s'", key, conditions.keys())
×
577
                raise KeyError(f"Cannot find key '{key}' in conditions: '{conditions.keys()}'")
×
578
            return result
×
579

580
        if stripped_fn_lower == "not":
1✔
581
            condition = value[keys_list[0]][0]
×
582
            condition = resolve_refs_recursively(
×
583
                account_id,
584
                region_name,
585
                stack_name,
586
                resources,
587
                mappings,
588
                conditions,
589
                parameters,
590
                condition,
591
            )
592
            return not condition
×
593

594
        if stripped_fn_lower in ["and", "or"]:
1✔
595
            conditions = value[keys_list[0]]
×
596
            results = [
×
597
                resolve_refs_recursively(
598
                    account_id,
599
                    region_name,
600
                    stack_name,
601
                    resources,
602
                    mappings,
603
                    conditions,
604
                    parameters,
605
                    cond,
606
                )
607
                for cond in conditions
608
            ]
609
            result = all(results) if stripped_fn_lower == "and" else any(results)
×
610
            return result
×
611

612
        if stripped_fn_lower == "equals":
1✔
613
            operand1, operand2 = value[keys_list[0]]
×
614
            operand1 = resolve_refs_recursively(
×
615
                account_id,
616
                region_name,
617
                stack_name,
618
                resources,
619
                mappings,
620
                conditions,
621
                parameters,
622
                operand1,
623
            )
624
            operand2 = resolve_refs_recursively(
×
625
                account_id,
626
                region_name,
627
                stack_name,
628
                resources,
629
                mappings,
630
                conditions,
631
                parameters,
632
                operand2,
633
            )
634
            # TODO: investigate type coercion here
635
            return fn_equals_type_conversion(operand1) == fn_equals_type_conversion(operand2)
×
636

637
        if stripped_fn_lower == "select":
1✔
638
            index, values = value[keys_list[0]]
×
639
            index = resolve_refs_recursively(
×
640
                account_id,
641
                region_name,
642
                stack_name,
643
                resources,
644
                mappings,
645
                conditions,
646
                parameters,
647
                index,
648
            )
649
            values = resolve_refs_recursively(
×
650
                account_id,
651
                region_name,
652
                stack_name,
653
                resources,
654
                mappings,
655
                conditions,
656
                parameters,
657
                values,
658
            )
659
            try:
×
660
                return values[index]
×
661
            except TypeError:
×
662
                return values[int(index)]
×
663

664
        if stripped_fn_lower == "split":
1✔
665
            delimiter, string = value[keys_list[0]]
1✔
666
            delimiter = resolve_refs_recursively(
1✔
667
                account_id,
668
                region_name,
669
                stack_name,
670
                resources,
671
                mappings,
672
                conditions,
673
                parameters,
674
                delimiter,
675
            )
676
            string = resolve_refs_recursively(
1✔
677
                account_id,
678
                region_name,
679
                stack_name,
680
                resources,
681
                mappings,
682
                conditions,
683
                parameters,
684
                string,
685
            )
686
            return string.split(delimiter)
1✔
687

688
        if stripped_fn_lower == "getazs":
×
689
            region = (
×
690
                resolve_refs_recursively(
691
                    account_id,
692
                    region_name,
693
                    stack_name,
694
                    resources,
695
                    mappings,
696
                    conditions,
697
                    parameters,
698
                    value["Fn::GetAZs"],
699
                )
700
                or region_name
701
            )
702

703
            ec2_client = connect_to(aws_access_key_id=account_id, region_name=region).ec2
×
704
            try:
×
705
                get_availability_zones = ec2_client.describe_availability_zones()[
×
706
                    "AvailabilityZones"
707
                ]
708
            except ClientError:
×
709
                LOG.error("client error describing availability zones")
×
710
                raise
×
711

712
            azs = [az["ZoneName"] for az in get_availability_zones]
×
713

714
            return azs
×
715

716
        if stripped_fn_lower == "base64":
×
717
            value_to_encode = value[keys_list[0]]
×
718
            value_to_encode = resolve_refs_recursively(
×
719
                account_id,
720
                region_name,
721
                stack_name,
722
                resources,
723
                mappings,
724
                conditions,
725
                parameters,
726
                value_to_encode,
727
            )
728
            return to_str(base64.b64encode(to_bytes(value_to_encode)))
×
729

730
        for key, val in dict(value).items():
×
731
            value[key] = resolve_refs_recursively(
×
732
                account_id,
733
                region_name,
734
                stack_name,
735
                resources,
736
                mappings,
737
                conditions,
738
                parameters,
739
                val,
740
            )
741

742
    if isinstance(value, list):
1✔
743
        # in some cases, intrinsic functions are passed in as, e.g., `[['Fn::Sub', '${MyRef}']]`
744
        if len(value) == 1 and isinstance(value[0], list) and len(value[0]) == 2:
1✔
745
            inner_list = value[0]
×
746
            if str(inner_list[0]).lower().startswith("fn::"):
×
747
                return resolve_refs_recursively(
×
748
                    account_id,
749
                    region_name,
750
                    stack_name,
751
                    resources,
752
                    mappings,
753
                    conditions,
754
                    parameters,
755
                    {inner_list[0]: inner_list[1]},
756
                )
757

758
        # remove _aws_no_value_ from resulting references
759
        clean_list = []
1✔
760
        for item in value:
1✔
761
            temp_value = resolve_refs_recursively(
1✔
762
                account_id,
763
                region_name,
764
                stack_name,
765
                resources,
766
                mappings,
767
                conditions,
768
                parameters,
769
                item,
770
            )
771
            if not (isinstance(temp_value, str) and temp_value == PLACEHOLDER_AWS_NO_VALUE):
1✔
772
                clean_list.append(temp_value)
1✔
773
        value = clean_list
1✔
774

775
    return value
1✔
776

777

778
def resolve_placeholders_in_string(
1✔
779
    account_id: str,
780
    region_name: str,
781
    result,
782
    stack_name: str,
783
    resources: dict,
784
    mappings: dict,
785
    conditions: dict[str, bool],
786
    parameters: dict,
787
):
788
    """
789
    Resolve individual Fn::Sub variable replacements
790

791
    Variables can be template parameter names, resource logical IDs, resource attributes, or a variable in a key-value map
792
    """
793

794
    def _validate_result_type(value: str):
×
795
        is_another_account_id = value.isdigit() and len(value) == len(account_id)
×
796
        if value == account_id or is_another_account_id:
×
797
            return value
×
798

799
        if value.isdigit():
×
800
            return int(value)
×
801
        else:
802
            try:
×
803
                res = float(value)
×
804
                return res
×
805
            except ValueError:
×
806
                return value
×
807

808
    def _replace(match):
×
809
        ref_expression = match.group(1)
×
810
        parts = ref_expression.split(".")
×
811
        if len(parts) >= 2:
×
812
            # Resource attributes specified => Use GetAtt to resolve
813
            logical_resource_id, _, attr_name = ref_expression.partition(".")
×
814
            resolved = get_attr_from_model_instance(
×
815
                resources[logical_resource_id],
816
                attr_name,
817
                get_resource_type(resources[logical_resource_id]),
818
                logical_resource_id,
819
            )
820
            if resolved is None:
×
821
                raise DependencyNotYetSatisfied(
×
822
                    resource_ids=logical_resource_id,
823
                    message=f"Unable to resolve attribute ref {ref_expression}",
824
                )
825
            if not isinstance(resolved, str):
×
826
                resolved = str(resolved)
×
827
            return resolved
×
828
        if len(parts) == 1:
×
829
            if parts[0] in resources or parts[0].startswith("AWS::"):
×
830
                # Logical resource ID or parameter name specified => Use Ref for lookup
831
                result = resolve_ref(
×
832
                    account_id, region_name, stack_name, resources, parameters, parts[0]
833
                )
834

835
                if result is None:
×
836
                    raise DependencyNotYetSatisfied(
×
837
                        resource_ids=parts[0],
838
                        message=f"Unable to resolve attribute ref {ref_expression}",
839
                    )
840
                # TODO: is this valid?
841
                # make sure we resolve any functions/placeholders in the extracted string
842
                result = resolve_refs_recursively(
×
843
                    account_id,
844
                    region_name,
845
                    stack_name,
846
                    resources,
847
                    mappings,
848
                    conditions,
849
                    parameters,
850
                    result,
851
                )
852
                # make sure we convert the result to string
853
                # TODO: do this more systematically
854
                result = "" if result is None else str(result)
×
855
                return result
×
856
            elif parts[0] in parameters:
×
857
                parameter = parameters[parts[0]]
×
858
                parameter_type: str = parameter["ParameterType"]
×
859
                parameter_value = parameter.get("ResolvedValue") or parameter.get("ParameterValue")
×
860

861
                if parameter_type in ["CommaDelimitedList"] or parameter_type.startswith("List<"):
×
862
                    return [p.strip() for p in parameter_value.split(",")]
×
863
                elif parameter_type == "Number":
×
864
                    return str(parameter_value)
×
865
                else:
866
                    return parameter_value
×
867
            else:
868
                raise DependencyNotYetSatisfied(
×
869
                    resource_ids=parts[0],
870
                    message=f"Unable to resolve attribute ref {ref_expression}",
871
                )
872
        # TODO raise exception here?
873
        return match.group(0)
×
874

875
    regex = r"\$\{([^\}]+)\}"
×
876
    result = re.sub(regex, _replace, result)
×
877
    return _validate_result_type(result)
×
878

879

880
def evaluate_resource_condition(conditions: dict[str, bool], resource: dict) -> bool:
1✔
881
    if condition := resource.get("Condition"):
×
882
        return conditions.get(condition, True)
×
883
    return True
×
884

885

886
# -----------------------
887
# MAIN TEMPLATE DEPLOYER
888
# -----------------------
889

890

891
class TemplateDeployer:
1✔
892
    def __init__(self, account_id: str, region_name: str, stack):
1✔
893
        self.stack = stack
×
894
        self.account_id = account_id
×
895
        self.region_name = region_name
×
896

897
    @property
1✔
898
    def resources(self):
1✔
899
        return self.stack.resources
×
900

901
    @property
1✔
902
    def mappings(self):
1✔
903
        return self.stack.mappings
×
904

905
    @property
1✔
906
    def stack_name(self):
1✔
907
        return self.stack.stack_name
×
908

909
    # ------------------
910
    # MAIN ENTRY POINTS
911
    # ------------------
912

913
    def deploy_stack(self):
1✔
914
        self.stack.set_stack_status("CREATE_IN_PROGRESS")
×
915
        try:
×
916
            self.apply_changes(
×
917
                self.stack,
918
                self.stack,
919
                initialize=True,
920
                action="CREATE",
921
            )
922
        except Exception as e:
×
923
            log_method = LOG.info
×
924
            if config.CFN_VERBOSE_ERRORS:
×
925
                log_method = LOG.exception
×
926
            log_method("Unable to create stack %s: %s", self.stack.stack_name, e)
×
927
            self.stack.set_stack_status("CREATE_FAILED")
×
928
            raise
×
929

930
    def apply_change_set(self, change_set: StackChangeSet):
1✔
931
        action = (
×
932
            "UPDATE"
933
            if change_set.stack.status in {"CREATE_COMPLETE", "UPDATE_COMPLETE"}
934
            else "CREATE"
935
        )
936
        change_set.stack.set_stack_status(f"{action}_IN_PROGRESS")
×
937
        # update parameters on parent stack
938
        change_set.stack.set_resolved_parameters(change_set.resolved_parameters)
×
939
        # update conditions on parent stack
940
        change_set.stack.set_resolved_stack_conditions(change_set.resolved_conditions)
×
941

942
        # update attributes that the stack inherits from the changeset
943
        change_set.stack.metadata["Capabilities"] = change_set.metadata.get("Capabilities")
×
944

945
        try:
×
946
            self.apply_changes(
×
947
                change_set.stack,
948
                change_set,
949
                action=action,
950
            )
951
        except Exception as e:
×
952
            LOG.info(
×
953
                "Unable to apply change set %s: %s", change_set.metadata.get("ChangeSetName"), e
954
            )
955
            change_set.metadata["Status"] = f"{action}_FAILED"
×
956
            self.stack.set_stack_status(f"{action}_FAILED")
×
957
            raise
×
958

959
    def update_stack(self, new_stack):
1✔
960
        self.stack.set_stack_status("UPDATE_IN_PROGRESS")
×
961
        # apply changes
962
        self.apply_changes(self.stack, new_stack, action="UPDATE")
×
963
        self.stack.set_time_attribute("LastUpdatedTime")
×
964

965
    # ----------------------------
966
    # DEPENDENCY RESOLUTION UTILS
967
    # ----------------------------
968

969
    def is_deployed(self, resource):
1✔
970
        return self.stack.resource_states.get(resource["LogicalResourceId"], {}).get(
×
971
            "ResourceStatus"
972
        ) in [
973
            "CREATE_COMPLETE",
974
            "UPDATE_COMPLETE",
975
        ]
976

977
    def all_resource_dependencies_satisfied(self, resource) -> bool:
1✔
978
        unsatisfied = self.get_unsatisfied_dependencies(resource)
×
979
        return not unsatisfied
×
980

981
    def get_unsatisfied_dependencies(self, resource):
1✔
982
        res_deps = self.get_resource_dependencies(
×
983
            resource
984
        )  # the output here is currently a set of merged IDs from both resources and parameters
985
        parameter_deps = {d for d in res_deps if d in self.stack.resolved_parameters}
×
986
        resource_deps = res_deps.difference(parameter_deps)
×
987
        res_deps_mapped = {v: self.stack.resources.get(v) for v in resource_deps}
×
988
        return self.get_unsatisfied_dependencies_for_resources(res_deps_mapped, resource)
×
989

990
    def get_unsatisfied_dependencies_for_resources(
1✔
991
        self, resources, depending_resource=None, return_first=True
992
    ):
993
        result = {}
×
994
        for resource_id, resource in resources.items():
×
995
            if not resource:
×
996
                raise Exception(
×
997
                    f"Resource '{resource_id}' not found in stack {self.stack.stack_name}"
998
                )
999
            if not self.is_deployed(resource):
×
1000
                LOG.debug(
×
1001
                    "Dependency for resource %s not yet deployed: %s %s",
1002
                    depending_resource,
1003
                    resource_id,
1004
                    resource,
1005
                )
1006
                result[resource_id] = resource
×
1007
                if return_first:
×
1008
                    break
×
1009
        return result
×
1010

1011
    def get_resource_dependencies(self, resource: dict) -> set[str]:
1✔
1012
        """
1013
        Takes a resource and returns its dependencies on other resources via a str -> str mapping
1014
        """
1015
        # Note: using the original, unmodified template here to preserve Ref's ...
1016
        raw_resources = self.stack.template_original["Resources"]
×
1017
        raw_resource = raw_resources[resource["LogicalResourceId"]]
×
1018
        return get_deps_for_resource(raw_resource, self.stack.resolved_conditions)
×
1019

1020
    # -----------------
1021
    # DEPLOYMENT UTILS
1022
    # -----------------
1023

1024
    def init_resource_status(self, resources=None, stack=None, action="CREATE"):
1✔
1025
        resources = resources or self.resources
×
1026
        stack = stack or self.stack
×
1027
        for resource_id, resource in resources.items():
×
1028
            stack.set_resource_status(resource_id, f"{action}_IN_PROGRESS")
×
1029

1030
    def get_change_config(
1✔
1031
        self, action: str, resource: dict, change_set_id: str | None = None
1032
    ) -> ChangeConfig:
1033
        result = ChangeConfig(
×
1034
            **{
1035
                "Type": "Resource",
1036
                "ResourceChange": ResourceChange(
1037
                    **{
1038
                        "Action": action,
1039
                        # TODO(srw): how can the resource not contain a logical resource id?
1040
                        "LogicalResourceId": resource.get("LogicalResourceId"),
1041
                        "PhysicalResourceId": resource.get("PhysicalResourceId"),
1042
                        "ResourceType": resource["Type"],
1043
                        # TODO ChangeSetId is only set for *nested* change sets
1044
                        # "ChangeSetId": change_set_id,
1045
                        "Scope": [],  # TODO
1046
                        "Details": [],  # TODO
1047
                    }
1048
                ),
1049
            }
1050
        )
1051
        if action == "Modify":
×
1052
            result["ResourceChange"]["Replacement"] = "False"
×
1053
        return result
×
1054

1055
    def resource_config_differs(self, resource_new):
1✔
1056
        """Return whether the given resource properties differ from the existing config (for stack updates)."""
1057
        # TODO: this is broken for default fields and result_handler property modifications when they're added to the properties in the model
1058
        resource_id = resource_new["LogicalResourceId"]
×
1059
        resource_old = self.resources[resource_id]
×
1060
        props_old = resource_old.get("SpecifiedProperties", {})
×
1061
        props_new = resource_new["Properties"]
×
1062
        ignored_keys = ["LogicalResourceId", "PhysicalResourceId"]
×
1063
        old_keys = set(props_old.keys()) - set(ignored_keys)
×
1064
        new_keys = set(props_new.keys()) - set(ignored_keys)
×
1065
        if old_keys != new_keys:
×
1066
            return True
×
1067
        for key in old_keys:
×
1068
            if props_old[key] != props_new[key]:
×
1069
                return True
×
1070
        old_status = self.stack.resource_states.get(resource_id) or {}
×
1071
        previous_state = (
×
1072
            old_status.get("PreviousResourceStatus") or old_status.get("ResourceStatus") or ""
1073
        )
1074
        if old_status and "DELETE" in previous_state:
×
1075
            return True
×
1076

1077
    # TODO: ?
1078
    def merge_properties(self, resource_id: str, old_stack, new_stack) -> None:
1✔
1079
        old_resources = old_stack.template["Resources"]
×
1080
        new_resources = new_stack.template["Resources"]
×
1081
        new_resource = new_resources[resource_id]
×
1082

1083
        old_resource = old_resources[resource_id] = old_resources.get(resource_id) or {}
×
1084
        for key, value in new_resource.items():
×
1085
            if key == "Properties":
×
1086
                continue
×
1087
            old_resource[key] = old_resource.get(key, value)
×
1088
        old_res_props = old_resource["Properties"] = old_resource.get("Properties", {})
×
1089
        for key, value in new_resource["Properties"].items():
×
1090
            old_res_props[key] = value
×
1091

1092
        old_res_props = {
×
1093
            k: v for k, v in old_res_props.items() if k in new_resource["Properties"].keys()
1094
        }
1095
        old_resource["Properties"] = old_res_props
×
1096

1097
        # overwrite original template entirely
1098
        old_stack.template_original["Resources"][resource_id] = new_stack.template_original[
×
1099
            "Resources"
1100
        ][resource_id]
1101

1102
    def construct_changes(
1✔
1103
        self,
1104
        existing_stack,
1105
        new_stack,
1106
        # TODO: remove initialize argument from here, and determine action based on resource status
1107
        initialize: bool | None = False,
1108
        change_set_id=None,
1109
        append_to_changeset: bool | None = False,
1110
        filter_unchanged_resources: bool | None = False,
1111
    ) -> list[ChangeConfig]:
1112
        old_resources = existing_stack.template["Resources"]
×
1113
        new_resources = new_stack.template["Resources"]
×
1114
        deletes = [val for key, val in old_resources.items() if key not in new_resources]
×
1115
        adds = [val for key, val in new_resources.items() if initialize or key not in old_resources]
×
1116
        modifies = [
×
1117
            val for key, val in new_resources.items() if not initialize and key in old_resources
1118
        ]
1119

1120
        changes = []
×
1121
        for action, items in (("Remove", deletes), ("Add", adds), ("Modify", modifies)):
×
1122
            for item in items:
×
1123
                item["Properties"] = item.get("Properties", {})
×
1124
                if (
×
1125
                    not filter_unchanged_resources  # TODO: find out purpose of this
1126
                    or action != "Modify"
1127
                    or self.resource_config_differs(item)
1128
                ):
1129
                    change = self.get_change_config(action, item, change_set_id=change_set_id)
×
1130
                    changes.append(change)
×
1131

1132
        # append changes to change set
1133
        if append_to_changeset and isinstance(new_stack, StackChangeSet):
×
1134
            new_stack.changes.extend(changes)
×
1135

1136
        return changes
×
1137

1138
    def apply_changes(
1✔
1139
        self,
1140
        existing_stack: Stack,
1141
        new_stack: StackChangeSet,
1142
        change_set_id: str | None = None,
1143
        initialize: bool | None = False,
1144
        action: str | None = None,
1145
    ):
1146
        old_resources = existing_stack.template["Resources"]
×
1147
        new_resources = new_stack.template["Resources"]
×
1148
        action = action or "CREATE"
×
1149
        # TODO: this seems wrong, not every resource here will be in an UPDATE_IN_PROGRESS state? (only the ones that will actually be updated)
1150
        self.init_resource_status(old_resources, action="UPDATE")
×
1151

1152
        # apply parameter changes to existing stack
1153
        # self.apply_parameter_changes(existing_stack, new_stack)
1154

1155
        # construct changes
1156
        changes = self.construct_changes(
×
1157
            existing_stack,
1158
            new_stack,
1159
            initialize=initialize,
1160
            change_set_id=change_set_id,
1161
        )
1162

1163
        # check if we have actual changes in the stack, and prepare properties
1164
        contains_changes = False
×
1165
        for change in changes:
×
1166
            res_action = change["ResourceChange"]["Action"]
×
1167
            resource = new_resources.get(change["ResourceChange"]["LogicalResourceId"])
×
1168
            #  FIXME: we need to resolve refs before diffing to detect if for example a parameter causes the change or not
1169
            #   unfortunately this would currently cause issues because we might not be able to resolve everything yet
1170
            # resource = resolve_refs_recursively(
1171
            #     self.stack_name,
1172
            #     self.resources,
1173
            #     self.mappings,
1174
            #     self.stack.resolved_conditions,
1175
            #     self.stack.resolved_parameters,
1176
            #     resource,
1177
            # )
1178
            if res_action in ["Add", "Remove"] or self.resource_config_differs(resource):
×
1179
                contains_changes = True
×
1180
            if res_action in ["Modify", "Add"]:
×
1181
                # mutating call that overwrites resource properties with new properties and overwrites the template in old stack with new template
1182
                self.merge_properties(resource["LogicalResourceId"], existing_stack, new_stack)
×
1183
        if not contains_changes:
×
1184
            raise NoStackUpdates("No updates are to be performed.")
×
1185

1186
        # merge stack outputs and conditions
1187
        existing_stack.outputs.update(new_stack.outputs)
×
1188
        existing_stack.conditions.update(new_stack.conditions)
×
1189

1190
        # TODO: ideally the entire template has to be replaced, but tricky at this point
1191
        existing_stack.template["Metadata"] = new_stack.template.get("Metadata")
×
1192
        existing_stack.template_body = new_stack.template_body
×
1193

1194
        # start deployment loop
1195
        return self.apply_changes_in_loop(
×
1196
            changes, existing_stack, action=action, new_stack=new_stack
1197
        )
1198

1199
    def apply_changes_in_loop(
1✔
1200
        self,
1201
        changes: list[ChangeConfig],
1202
        stack: Stack,
1203
        action: str | None = None,
1204
        new_stack=None,
1205
    ):
1206
        def _run(*args):
×
1207
            status_reason = None
×
1208
            try:
×
1209
                self.do_apply_changes_in_loop(changes, stack)
×
1210
                status = f"{action}_COMPLETE"
×
1211
            except Exception as e:
×
1212
                log_method = LOG.debug
×
1213
                if config.CFN_VERBOSE_ERRORS:
×
1214
                    log_method = LOG.exception
×
1215
                log_method(
×
1216
                    'Error applying changes for CloudFormation stack "%s": %s %s',
1217
                    stack.stack_name,
1218
                    e,
1219
                    traceback.format_exc(),
1220
                )
1221
                status = f"{action}_FAILED"
×
1222
                status_reason = str(e)
×
1223
            stack.set_stack_status(status, status_reason)
×
1224
            if isinstance(new_stack, StackChangeSet):
×
1225
                new_stack.metadata["Status"] = status
×
1226
                exec_result = "EXECUTE_FAILED" if "FAILED" in status else "EXECUTE_COMPLETE"
×
1227
                new_stack.metadata["ExecutionStatus"] = exec_result
×
1228
                result = "failed" if "FAILED" in status else "succeeded"
×
1229
                new_stack.metadata["StatusReason"] = status_reason or f"Deployment {result}"
×
1230

1231
        # run deployment in background loop, to avoid client network timeouts
1232
        return start_worker_thread(_run)
×
1233

1234
    def prepare_should_deploy_change(
1✔
1235
        self, resource_id: str, change: ResourceChange, stack, new_resources: dict
1236
    ) -> bool:
1237
        """
1238
        TODO: document
1239
        """
1240
        resource = new_resources[resource_id]
×
1241
        res_change = change["ResourceChange"]
×
1242
        action = res_change["Action"]
×
1243

1244
        # check resource condition, if present
1245
        if not evaluate_resource_condition(stack.resolved_conditions, resource):
×
1246
            LOG.debug(
×
1247
                'Skipping deployment of "%s", as resource condition evaluates to false', resource_id
1248
            )
1249
            return False
×
1250

1251
        # resolve refs in resource details
1252
        resolve_refs_recursively(
×
1253
            self.account_id,
1254
            self.region_name,
1255
            stack.stack_name,
1256
            stack.resources,
1257
            stack.mappings,
1258
            stack.resolved_conditions,
1259
            stack.resolved_parameters,
1260
            resource,
1261
        )
1262

1263
        if action in ["Add", "Modify"]:
×
1264
            is_deployed = self.is_deployed(resource)
×
1265
            # TODO: Attaching the cached _deployed info here, as we should not change the "Add"/"Modify" attribute
1266
            #  here, which is used further down the line to determine the resource action CREATE/UPDATE. This is a
1267
            #  temporary workaround for now - to be refactored once we introduce proper stack resource state models.
1268
            res_change["_deployed"] = is_deployed
×
1269
            if not is_deployed:
×
1270
                return True
×
1271
            if action == "Add":
×
1272
                return False
×
1273
        elif action == "Remove":
×
1274
            return True
×
1275
        return True
×
1276

1277
    # Stack is needed here
1278
    def apply_change(self, change: ChangeConfig, stack: Stack) -> None:
1✔
1279
        change_details = change["ResourceChange"]
×
1280
        action = change_details["Action"]
×
1281
        resource_id = change_details["LogicalResourceId"]
×
1282
        resources = stack.resources
×
1283
        resource = resources[resource_id]
×
1284

1285
        # TODO: this should not be needed as resources are filtered out if the
1286
        # condition evaluates to False.
1287
        if not evaluate_resource_condition(stack.resolved_conditions, resource):
×
1288
            return
×
1289

1290
        # remove AWS::NoValue entries
1291
        resource_props = resource.get("Properties")
×
1292
        if resource_props:
×
1293
            resource["Properties"] = remove_none_values(resource_props)
×
1294

1295
        executor = self.create_resource_provider_executor()
×
1296
        resource_provider_payload = self.create_resource_provider_payload(
×
1297
            action, logical_resource_id=resource_id
1298
        )
1299

1300
        resource_type = get_resource_type(resource)
×
1301
        resource_provider = executor.try_load_resource_provider(resource_type)
×
1302
        track_resource_operation(action, resource_type, missing=resource_provider is None)
×
1303
        if resource_provider is not None:
×
1304
            # add in-progress event
1305
            resource_status = f"{get_action_name_for_resource_change(action)}_IN_PROGRESS"
×
1306
            physical_resource_id = None
×
1307
            if action in ("Modify", "Remove"):
×
1308
                previous_state = self.resources[resource_id].get("_last_deployed_state")
×
1309
                if not previous_state:
×
1310
                    # TODO: can this happen?
1311
                    previous_state = self.resources[resource_id]["Properties"]
×
1312
                physical_resource_id = executor.extract_physical_resource_id_from_model_with_schema(
×
1313
                    resource_model=previous_state,
1314
                    resource_type=resource["Type"],
1315
                    resource_type_schema=resource_provider.SCHEMA,
1316
                )
1317
            stack.add_stack_event(
×
1318
                resource_id=resource_id,
1319
                physical_res_id=physical_resource_id,
1320
                status=resource_status,
1321
            )
1322

1323
            # perform the deploy
1324
            progress_event = executor.deploy_loop(
×
1325
                resource_provider, resource, resource_provider_payload
1326
            )
1327
        else:
1328
            # track that we don't handle the resource, and possibly raise an exception
1329
            log_not_available_message(
×
1330
                resource_type,
1331
                f'No resource provider found for "{resource_type}"',
1332
            )
1333

1334
            if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
×
1335
                raise NoResourceProvider
×
1336

1337
            resource["PhysicalResourceId"] = MOCK_REFERENCE
×
1338
            progress_event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
×
1339

1340
        # TODO: clean up the surrounding loop (do_apply_changes_in_loop) so that the responsibilities are clearer
1341
        stack_action = get_action_name_for_resource_change(action)
×
1342
        match progress_event.status:
×
1343
            case OperationStatus.FAILED:
×
1344
                stack.set_resource_status(
×
1345
                    resource_id,
1346
                    f"{stack_action}_FAILED",
1347
                    status_reason=progress_event.message or "",
1348
                )
1349
                # TODO: remove exception raising here?
1350
                # TODO: fix request token
1351
                raise Exception(
×
1352
                    f'Resource handler returned message: "{progress_event.message}" (RequestToken: 10c10335-276a-33d3-5c07-018b684c3d26, HandlerErrorCode: InvalidRequest){progress_event.error_code}'
1353
                )
1354
            case OperationStatus.SUCCESS:
×
1355
                stack.set_resource_status(resource_id, f"{stack_action}_COMPLETE")
×
1356
            case OperationStatus.PENDING:
×
1357
                # signal to the main loop that we should come back to this resource in the future
1358
                raise DependencyNotYetSatisfied(
×
1359
                    resource_ids=[], message="Resource dependencies not yet satisfied"
1360
                )
1361
            case OperationStatus.IN_PROGRESS:
×
1362
                raise Exception("Resource deployment loop should not finish in this state")
×
1363
            case unknown_status:
×
1364
                raise Exception(f"Unknown operation status: {unknown_status}")
×
1365

1366
        # TODO: this is probably already done in executor, try removing this
1367
        resource["Properties"] = progress_event.resource_model
×
1368

1369
    def create_resource_provider_executor(self) -> ResourceProviderExecutor:
1✔
1370
        return ResourceProviderExecutor(
×
1371
            stack_name=self.stack.stack_name,
1372
            stack_id=self.stack.stack_id,
1373
        )
1374

1375
    def create_resource_provider_payload(
1✔
1376
        self, action: str, logical_resource_id: str
1377
    ) -> ResourceProviderPayload:
1378
        # FIXME: use proper credentials
1379
        creds: Credentials = {
×
1380
            "accessKeyId": self.account_id,
1381
            "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY,
1382
            "sessionToken": "",
1383
        }
1384
        resource = self.resources[logical_resource_id]
×
1385

1386
        resource_provider_payload: ResourceProviderPayload = {
×
1387
            "awsAccountId": self.account_id,
1388
            "callbackContext": {},
1389
            "stackId": self.stack.stack_name,
1390
            "resourceType": resource["Type"],
1391
            "resourceTypeVersion": "000000",
1392
            # TODO: not actually a UUID
1393
            "bearerToken": str(uuid.uuid4()),
1394
            "region": self.region_name,
1395
            "action": action,
1396
            "requestData": {
1397
                "logicalResourceId": logical_resource_id,
1398
                "resourceProperties": resource["Properties"],
1399
                "previousResourceProperties": resource.get("_last_deployed_state"),  # TODO
1400
                "callerCredentials": creds,
1401
                "providerCredentials": creds,
1402
                "systemTags": {},
1403
                "previousSystemTags": {},
1404
                "stackTags": {},
1405
                "previousStackTags": {},
1406
            },
1407
        }
1408
        return resource_provider_payload
×
1409

1410
    def delete_stack(self):
1✔
1411
        if not self.stack:
×
1412
            return
×
1413
        self.stack.set_stack_status("DELETE_IN_PROGRESS")
×
1414
        stack_resources = list(self.stack.resources.values())
×
1415
        resources = {r["LogicalResourceId"]: clone_safe(r) for r in stack_resources}
×
1416
        original_resources = self.stack.template_original["Resources"]
×
1417

1418
        # TODO: what is this doing?
1419
        for key, resource in resources.items():
×
1420
            resource["Properties"] = resource.get(
×
1421
                "Properties", clone_safe(resource)
1422
            )  # TODO: why is there a fallback?
1423
            resource["ResourceType"] = get_resource_type(resource)
×
1424

1425
        ordered_resource_ids = list(
×
1426
            order_resources(
1427
                resources=original_resources,
1428
                resolved_conditions=self.stack.resolved_conditions,
1429
                resolved_parameters=self.stack.resolved_parameters,
1430
                reverse=True,
1431
            ).keys()
1432
        )
1433
        for i, resource_id in enumerate(ordered_resource_ids):
×
1434
            resource = resources[resource_id]
×
1435
            resource_type = get_resource_type(resource)
×
1436
            try:
×
1437
                # TODO: cache condition value in resource details on deployment and use cached value here
1438
                if not evaluate_resource_condition(
×
1439
                    self.stack.resolved_conditions,
1440
                    resource,
1441
                ):
1442
                    continue
×
1443

1444
                action = "Remove"
×
1445
                executor = self.create_resource_provider_executor()
×
1446
                resource_provider_payload = self.create_resource_provider_payload(
×
1447
                    action, logical_resource_id=resource_id
1448
                )
1449
                LOG.debug(
×
1450
                    'Handling "Remove" for resource "%s" (%s/%s) type "%s"',
1451
                    resource_id,
1452
                    i + 1,
1453
                    len(resources),
1454
                    resource_type,
1455
                )
1456
                resource_provider = executor.try_load_resource_provider(resource_type)
×
1457
                track_resource_operation(action, resource_type, missing=resource_provider is None)
×
1458
                if resource_provider is not None:
×
1459
                    event = executor.deploy_loop(
×
1460
                        resource_provider, resource, resource_provider_payload
1461
                    )
1462
                else:
1463
                    log_not_available_message(
×
1464
                        resource_type,
1465
                        f'No resource provider found for "{resource_type}"',
1466
                    )
1467

1468
                    if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
×
1469
                        raise NoResourceProvider
×
1470
                    event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
×
1471
                match event.status:
×
1472
                    case OperationStatus.SUCCESS:
×
1473
                        self.stack.set_resource_status(resource_id, "DELETE_COMPLETE")
×
1474
                    case OperationStatus.PENDING:
×
1475
                        # the resource is still being deleted, specifically the provider has
1476
                        # signalled that the deployment loop should skip this resource this
1477
                        # time and come back to it later, likely due to unmet child
1478
                        # resources still existing because we don't delete things in the
1479
                        # correct order yet.
1480
                        continue
×
1481
                    case OperationStatus.FAILED:
×
1482
                        LOG.error(
×
1483
                            "Failed to delete resource with id %s. Reason: %s",
1484
                            resource_id,
1485
                            event.message or "unknown",
1486
                            exc_info=LOG.isEnabledFor(logging.DEBUG),
1487
                        )
1488
                    case OperationStatus.IN_PROGRESS:
×
1489
                        # the resource provider executor should not return this state, so
1490
                        # this state is a programming error
1491
                        raise Exception(
×
1492
                            "Programming error: ResourceProviderExecutor cannot return IN_PROGRESS"
1493
                        )
1494
                    case other_status:
×
1495
                        raise Exception(f"Use of unsupported status found: {other_status}")
×
1496

1497
            except Exception as e:
×
1498
                LOG.error(
×
1499
                    "Failed to delete resource with id %s. Final exception: %s",
1500
                    resource_id,
1501
                    e,
1502
                    exc_info=LOG.isEnabledFor(logging.DEBUG),
1503
                )
1504

1505
        # update status
1506
        self.stack.set_stack_status("DELETE_COMPLETE")
×
1507
        self.stack.set_time_attribute("DeletionTime")
×
1508

1509
    def do_apply_changes_in_loop(self, changes: list[ChangeConfig], stack: Stack) -> list:
1✔
1510
        # apply changes in a retry loop, to resolve resource dependencies and converge to the target state
1511
        changes_done = []
×
1512
        new_resources = stack.resources
×
1513

1514
        sorted_changes = order_changes(
×
1515
            given_changes=changes,
1516
            resources=new_resources,
1517
            resolved_conditions=stack.resolved_conditions,
1518
            resolved_parameters=stack.resolved_parameters,
1519
        )
1520
        for change_idx, change in enumerate(sorted_changes):
×
1521
            res_change = change["ResourceChange"]
×
1522
            action = res_change["Action"]
×
1523
            is_add_or_modify = action in ["Add", "Modify"]
×
1524
            resource_id = res_change["LogicalResourceId"]
×
1525

1526
            # TODO: do resolve_refs_recursively once here
1527
            try:
×
1528
                if is_add_or_modify:
×
1529
                    should_deploy = self.prepare_should_deploy_change(
×
1530
                        resource_id, change, stack, new_resources
1531
                    )
1532
                    LOG.debug(
×
1533
                        'Handling "%s" for resource "%s" (%s/%s) type "%s" (should_deploy=%s)',
1534
                        action,
1535
                        resource_id,
1536
                        change_idx + 1,
1537
                        len(changes),
1538
                        res_change["ResourceType"],
1539
                        should_deploy,
1540
                    )
1541
                    if not should_deploy:
×
1542
                        stack_action = get_action_name_for_resource_change(action)
×
1543
                        stack.set_resource_status(resource_id, f"{stack_action}_COMPLETE")
×
1544
                        continue
×
1545
                elif action == "Remove":
×
1546
                    should_remove = self.prepare_should_deploy_change(
×
1547
                        resource_id, change, stack, new_resources
1548
                    )
1549
                    if not should_remove:
×
1550
                        continue
×
1551
                    LOG.debug(
×
1552
                        'Handling "%s" for resource "%s" (%s/%s) type "%s"',
1553
                        action,
1554
                        resource_id,
1555
                        change_idx + 1,
1556
                        len(changes),
1557
                        res_change["ResourceType"],
1558
                    )
1559
                self.apply_change(change, stack=stack)
×
1560
                changes_done.append(change)
×
1561
            except Exception as e:
×
1562
                status_action = {
×
1563
                    "Add": "CREATE",
1564
                    "Modify": "UPDATE",
1565
                    "Dynamic": "UPDATE",
1566
                    "Remove": "DELETE",
1567
                }[action]
1568
                stack.add_stack_event(
×
1569
                    resource_id=resource_id,
1570
                    physical_res_id=new_resources[resource_id].get("PhysicalResourceId"),
1571
                    status=f"{status_action}_FAILED",
1572
                    status_reason=str(e),
1573
                )
1574
                if config.CFN_VERBOSE_ERRORS:
×
1575
                    LOG.exception("Failed to deploy resource %s, stack deploy failed", resource_id)
×
1576
                raise
×
1577

1578
        # clean up references to deleted resources in stack
1579
        deletes = [c for c in changes_done if c["ResourceChange"]["Action"] == "Remove"]
×
1580
        for delete in deletes:
×
1581
            stack.template["Resources"].pop(delete["ResourceChange"]["LogicalResourceId"], None)
×
1582

1583
        # resolve outputs
1584
        stack.resolved_outputs = resolve_outputs(self.account_id, self.region_name, stack)
×
1585

1586
        return changes_done
×
1587

1588

1589
# FIXME: resolve_refs_recursively should not be needed, the resources themselves should have those values available already
1590
def resolve_outputs(account_id: str, region_name: str, stack) -> list[dict]:
1✔
1591
    result = []
×
1592
    for k, details in stack.outputs.items():
×
1593
        if not evaluate_resource_condition(stack.resolved_conditions, details):
×
1594
            continue
×
1595
        value = None
×
1596
        try:
×
1597
            resolve_refs_recursively(
×
1598
                account_id,
1599
                region_name,
1600
                stack.stack_name,
1601
                stack.resources,
1602
                stack.mappings,
1603
                stack.resolved_conditions,
1604
                stack.resolved_parameters,
1605
                details,
1606
            )
1607
            value = details["Value"]
×
1608
        except Exception as e:
×
1609
            log_method = LOG.debug
×
1610
            if config.CFN_VERBOSE_ERRORS:
×
1611
                raise  # unresolvable outputs cause a stack failure
×
1612
                # log_method = getattr(LOG, "exception")
1613
            log_method("Unable to resolve references in stack outputs: %s - %s", details, e)
×
1614
        exports = details.get("Export") or {}
×
1615
        export = exports.get("Name")
×
1616
        export = resolve_refs_recursively(
×
1617
            account_id,
1618
            region_name,
1619
            stack.stack_name,
1620
            stack.resources,
1621
            stack.mappings,
1622
            stack.resolved_conditions,
1623
            stack.resolved_parameters,
1624
            export,
1625
        )
1626
        description = details.get("Description")
×
1627
        entry = {
×
1628
            "OutputKey": k,
1629
            "OutputValue": value,
1630
            "Description": description,
1631
            "ExportName": export,
1632
        }
1633
        result.append(entry)
×
1634
    return result
×
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

© 2025 Coveralls, Inc