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

localstack / localstack / bf093a6f-b143-46ce-acf1-34f1a2978dc3

23 Jan 2025 09:34PM UTC coverage: 86.86% (-0.001%) from 86.861%
bf093a6f-b143-46ce-acf1-34f1a2978dc3

push

circleci

web-flow
S3: fix exception from ObjectLock retention (#12165)

4 of 4 new or added lines in 1 file covered. (100.0%)

92 existing lines in 6 files now uncovered.

61135 of 70383 relevant lines covered (86.86%)

0.87 hits per line

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

80.42
/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
from typing import Optional
1✔
8

9
from botocore.exceptions import ClientError
1✔
10

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

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

53
ACTION_CREATE = "create"
1✔
54
ACTION_DELETE = "delete"
1✔
55

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

61
LOG = logging.getLogger(__name__)
1✔
62

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

66
# Mock value for unsupported type references
67
MOCK_REFERENCE = "unknown"
1✔
68

69

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

73
    pass
1✔
74

75

76
# ---------------------
77
# CF TEMPLATE HANDLING
78
# ---------------------
79

80

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

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

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

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

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

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

143

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

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

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

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

198
    return resources[ref].get("PhysicalResourceId")
1✔
199

200

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

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

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

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

274
                kwargs = {}  # optional args for get_secret_value
1✔
275
                if version_id:
1✔
276
                    kwargs["VersionId"] = version_id
×
277
                if version_stage:
1✔
278
                    kwargs["VersionStage"] = version_stage
×
279

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

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

306
    return result
1✔
307

308

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

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

333
                raise DependencyNotYetSatisfied(resource_ids=value["Ref"], message=msg)
×
334

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

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

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

367
            resource_type = get_resource_type(resource)
1✔
368
            resolved_getatt = get_attr_from_model_instance(
1✔
369
                resource,
370
                attribute_name,
371
                resource_type,
372
                resource_logical_id,
373
                attribute_sub_name,
374
            )
375

376
            # 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.
377
            if resolved_getatt is None:
1✔
378
                raise DependencyNotYetSatisfied(
×
379
                    resource_ids=resource_logical_id,
380
                    message=f"Could not resolve attribute '{attribute_name}' on resource '{resource_logical_id}'",
381
                )
382

383
            return resolved_getatt
1✔
384

385
        if stripped_fn_lower == "join":
1✔
386
            join_values = value[keys_list[0]][1]
1✔
387

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

401
            join_values = [
1✔
402
                resolve_refs_recursively(
403
                    account_id,
404
                    region_name,
405
                    stack_name,
406
                    resources,
407
                    mappings,
408
                    conditions,
409
                    parameters,
410
                    v,
411
                )
412
                for v in join_values
413
            ]
414

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

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

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

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

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

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

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

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

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

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

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

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

521
            return first_level_mapping[second_level_attribute]
1✔
522

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

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

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

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

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

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

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

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

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

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

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

713
            azs = [az["ZoneName"] for az in get_availability_zones]
1✔
714

715
            return azs
1✔
716

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

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

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

759
        for i in range(len(value)):
1✔
760
            value[i] = resolve_refs_recursively(
1✔
761
                account_id,
762
                region_name,
763
                stack_name,
764
                resources,
765
                mappings,
766
                conditions,
767
                parameters,
768
                value[i],
769
            )
770

771
    return value
1✔
772

773

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

787
    Variables can be template parameter names, resource logical IDs, resource attributes, or a variable in a key-value map
788
    """
789

790
    def _validate_result_type(value: str):
1✔
791
        is_another_account_id = value.isdigit() and len(value) == len(account_id)
1✔
792
        if value == account_id or is_another_account_id:
1✔
UNCOV
793
            return value
×
794

795
        if value.isdigit():
1✔
796
            return int(value)
1✔
797
        else:
798
            try:
1✔
799
                res = float(value)
1✔
800
                return res
1✔
801
            except ValueError:
1✔
802
                return value
1✔
803

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

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

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

871
    regex = r"\$\{([^\}]+)\}"
1✔
872
    result = re.sub(regex, _replace, result)
1✔
873
    return _validate_result_type(result)
1✔
874

875

876
def evaluate_resource_condition(conditions: dict[str, bool], resource: dict) -> bool:
1✔
877
    if condition := resource.get("Condition"):
1✔
878
        return conditions.get(condition, True)
1✔
879
    return True
1✔
880

881

882
# -----------------------
883
# MAIN TEMPLATE DEPLOYER
884
# -----------------------
885

886

887
class TemplateDeployer:
1✔
888
    def __init__(self, account_id: str, region_name: str, stack):
1✔
889
        self.stack = stack
1✔
890
        self.account_id = account_id
1✔
891
        self.region_name = region_name
1✔
892

893
    @property
1✔
894
    def resources(self):
1✔
895
        return self.stack.resources
1✔
896

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

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

905
    # ------------------
906
    # MAIN ENTRY POINTS
907
    # ------------------
908

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

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

938
        # update attributes that the stack inherits from the changeset
939
        change_set.stack.metadata["Capabilities"] = change_set.metadata.get("Capabilities")
1✔
940

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

955
    def update_stack(self, new_stack):
1✔
956
        self.stack.set_stack_status("UPDATE_IN_PROGRESS")
1✔
957
        # apply changes
958
        self.apply_changes(self.stack, new_stack, action="UPDATE")
1✔
959
        self.stack.set_time_attribute("LastUpdatedTime")
1✔
960

961
    # ----------------------------
962
    # DEPENDENCY RESOLUTION UTILS
963
    # ----------------------------
964

965
    def is_deployed(self, resource):
1✔
966
        return self.stack.resource_states.get(resource["LogicalResourceId"], {}).get(
1✔
967
            "ResourceStatus"
968
        ) in [
969
            "CREATE_COMPLETE",
970
            "UPDATE_COMPLETE",
971
        ]
972

973
    def all_resource_dependencies_satisfied(self, resource) -> bool:
1✔
UNCOV
974
        unsatisfied = self.get_unsatisfied_dependencies(resource)
×
UNCOV
975
        return not unsatisfied
×
976

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

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

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

1016
    # -----------------
1017
    # DEPLOYMENT UTILS
1018
    # -----------------
1019

1020
    def init_resource_status(self, resources=None, stack=None, action="CREATE"):
1✔
1021
        resources = resources or self.resources
1✔
1022
        stack = stack or self.stack
1✔
1023
        for resource_id, resource in resources.items():
1✔
1024
            stack.set_resource_status(resource_id, f"{action}_IN_PROGRESS")
1✔
1025

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

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

1073
    # TODO: ?
1074
    def merge_properties(self, resource_id: str, old_stack, new_stack) -> None:
1✔
1075
        old_resources = old_stack.template["Resources"]
1✔
1076
        new_resources = new_stack.template["Resources"]
1✔
1077
        new_resource = new_resources[resource_id]
1✔
1078

1079
        old_resource = old_resources[resource_id] = old_resources.get(resource_id) or {}
1✔
1080
        for key, value in new_resource.items():
1✔
1081
            if key == "Properties":
1✔
1082
                continue
1✔
1083
            old_resource[key] = old_resource.get(key, value)
1✔
1084
        old_res_props = old_resource["Properties"] = old_resource.get("Properties", {})
1✔
1085
        for key, value in new_resource["Properties"].items():
1✔
1086
            old_res_props[key] = value
1✔
1087

1088
        old_res_props = {
1✔
1089
            k: v for k, v in old_res_props.items() if k in new_resource["Properties"].keys()
1090
        }
1091
        old_resource["Properties"] = old_res_props
1✔
1092

1093
        # overwrite original template entirely
1094
        old_stack.template_original["Resources"][resource_id] = new_stack.template_original[
1✔
1095
            "Resources"
1096
        ][resource_id]
1097

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

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

1128
        # append changes to change set
1129
        if append_to_changeset and isinstance(new_stack, StackChangeSet):
1✔
1130
            new_stack.changes.extend(changes)
1✔
1131

1132
        return changes
1✔
1133

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

1148
        # apply parameter changes to existing stack
1149
        # self.apply_parameter_changes(existing_stack, new_stack)
1150

1151
        # construct changes
1152
        changes = self.construct_changes(
1✔
1153
            existing_stack,
1154
            new_stack,
1155
            initialize=initialize,
1156
            change_set_id=change_set_id,
1157
        )
1158

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

1182
        # merge stack outputs and conditions
1183
        existing_stack.outputs.update(new_stack.outputs)
1✔
1184
        existing_stack.conditions.update(new_stack.conditions)
1✔
1185

1186
        # TODO: ideally the entire template has to be replaced, but tricky at this point
1187
        existing_stack.template["Metadata"] = new_stack.template.get("Metadata")
1✔
1188
        existing_stack.template_body = new_stack.template_body
1✔
1189

1190
        # start deployment loop
1191
        return self.apply_changes_in_loop(
1✔
1192
            changes, existing_stack, action=action, new_stack=new_stack
1193
        )
1194

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

1227
        # run deployment in background loop, to avoid client network timeouts
1228
        return start_worker_thread(_run)
1✔
1229

1230
    def prepare_should_deploy_change(
1✔
1231
        self, resource_id: str, change: ResourceChange, stack, new_resources: dict
1232
    ) -> bool:
1233
        """
1234
        TODO: document
1235
        """
1236
        resource = new_resources[resource_id]
1✔
1237
        res_change = change["ResourceChange"]
1✔
1238
        action = res_change["Action"]
1✔
1239

1240
        # check resource condition, if present
1241
        if not evaluate_resource_condition(stack.resolved_conditions, resource):
1✔
1242
            LOG.debug(
1✔
1243
                'Skipping deployment of "%s", as resource condition evaluates to false', resource_id
1244
            )
1245
            return False
1✔
1246

1247
        # resolve refs in resource details
1248
        resolve_refs_recursively(
1✔
1249
            self.account_id,
1250
            self.region_name,
1251
            stack.stack_name,
1252
            stack.resources,
1253
            stack.mappings,
1254
            stack.resolved_conditions,
1255
            stack.resolved_parameters,
1256
            resource,
1257
        )
1258

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

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

1281
        # TODO: this should not be needed as resources are filtered out if the
1282
        # condition evaluates to False.
1283
        if not evaluate_resource_condition(stack.resolved_conditions, resource):
1✔
UNCOV
1284
            return
×
1285

1286
        # remove AWS::NoValue entries
1287
        resource_props = resource.get("Properties")
1✔
1288
        if resource_props:
1✔
1289
            resource["Properties"] = remove_none_values(resource_props)
1✔
1290

1291
        executor = self.create_resource_provider_executor()
1✔
1292
        resource_provider_payload = self.create_resource_provider_payload(
1✔
1293
            action, logical_resource_id=resource_id
1294
        )
1295

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

1317
            # perform the deploy
1318
            progress_event = executor.deploy_loop(
1✔
1319
                resource_provider, resource, resource_provider_payload
1320
            )
1321
        else:
1322
            resource["PhysicalResourceId"] = MOCK_REFERENCE
1✔
1323
            progress_event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
1✔
1324

1325
        # TODO: clean up the surrounding loop (do_apply_changes_in_loop) so that the responsibilities are clearer
1326
        stack_action = get_action_name_for_resource_change(action)
1✔
1327
        match progress_event.status:
1✔
1328
            case OperationStatus.FAILED:
1✔
1329
                stack.set_resource_status(
1✔
1330
                    resource_id,
1331
                    f"{stack_action}_FAILED",
1332
                    status_reason=progress_event.message or "",
1333
                )
1334
                # TODO: remove exception raising here?
1335
                # TODO: fix request token
1336
                raise Exception(
1✔
1337
                    f'Resource handler returned message: "{progress_event.message}" (RequestToken: 10c10335-276a-33d3-5c07-018b684c3d26, HandlerErrorCode: InvalidRequest){progress_event.error_code}'
1338
                )
1339
            case OperationStatus.SUCCESS:
1✔
1340
                stack.set_resource_status(resource_id, f"{stack_action}_COMPLETE")
1✔
1341
            case OperationStatus.PENDING:
×
1342
                # signal to the main loop that we should come back to this resource in the future
UNCOV
1343
                raise DependencyNotYetSatisfied(
×
1344
                    resource_ids=[], message="Resource dependencies not yet satisfied"
1345
                )
1346
            case OperationStatus.IN_PROGRESS:
×
1347
                raise Exception("Resource deployment loop should not finish in this state")
×
UNCOV
1348
            case unknown_status:
×
UNCOV
1349
                raise Exception(f"Unknown operation status: {unknown_status}")
×
1350

1351
        # TODO: this is probably already done in executor, try removing this
1352
        resource["Properties"] = progress_event.resource_model
1✔
1353

1354
    def create_resource_provider_executor(self) -> ResourceProviderExecutor:
1✔
1355
        return ResourceProviderExecutor(
1✔
1356
            stack_name=self.stack.stack_name,
1357
            stack_id=self.stack.stack_id,
1358
        )
1359

1360
    def create_resource_provider_payload(
1✔
1361
        self, action: str, logical_resource_id: str
1362
    ) -> ResourceProviderPayload:
1363
        # FIXME: use proper credentials
1364
        creds: Credentials = {
1✔
1365
            "accessKeyId": self.account_id,
1366
            "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY,
1367
            "sessionToken": "",
1368
        }
1369
        resource = self.resources[logical_resource_id]
1✔
1370

1371
        resource_provider_payload: ResourceProviderPayload = {
1✔
1372
            "awsAccountId": self.account_id,
1373
            "callbackContext": {},
1374
            "stackId": self.stack.stack_name,
1375
            "resourceType": resource["Type"],
1376
            "resourceTypeVersion": "000000",
1377
            # TODO: not actually a UUID
1378
            "bearerToken": str(uuid.uuid4()),
1379
            "region": self.region_name,
1380
            "action": action,
1381
            "requestData": {
1382
                "logicalResourceId": logical_resource_id,
1383
                "resourceProperties": resource["Properties"],
1384
                "previousResourceProperties": resource.get("_last_deployed_state"),  # TODO
1385
                "callerCredentials": creds,
1386
                "providerCredentials": creds,
1387
                "systemTags": {},
1388
                "previousSystemTags": {},
1389
                "stackTags": {},
1390
                "previousStackTags": {},
1391
            },
1392
        }
1393
        return resource_provider_payload
1✔
1394

1395
    def delete_stack(self):
1✔
1396
        if not self.stack:
1✔
UNCOV
1397
            return
×
1398
        self.stack.set_stack_status("DELETE_IN_PROGRESS")
1✔
1399
        stack_resources = list(self.stack.resources.values())
1✔
1400
        resources = {r["LogicalResourceId"]: clone_safe(r) for r in stack_resources}
1✔
1401
        original_resources = self.stack.template_original["Resources"]
1✔
1402

1403
        # TODO: what is this doing?
1404
        for key, resource in resources.items():
1✔
1405
            resource["Properties"] = resource.get(
1✔
1406
                "Properties", clone_safe(resource)
1407
            )  # TODO: why is there a fallback?
1408
            resource["ResourceType"] = get_resource_type(resource)
1✔
1409

1410
        def _safe_lookup_is_deleted(r_id):
1✔
1411
            """handles the case where self.stack.resource_status(..) fails for whatever reason"""
1412
            try:
×
1413
                return self.stack.resource_status(r_id).get("ResourceStatus") == "DELETE_COMPLETE"
×
1414
            except Exception:
×
1415
                if config.CFN_VERBOSE_ERRORS:
×
UNCOV
1416
                    LOG.exception("failed to lookup if resource %s is deleted", r_id)
×
UNCOV
1417
                return True  # just an assumption
×
1418

1419
        ordered_resource_ids = list(
1✔
1420
            order_resources(
1421
                resources=original_resources,
1422
                resolved_conditions=self.stack.resolved_conditions,
1423
                resolved_parameters=self.stack.resolved_parameters,
1424
                reverse=True,
1425
            ).keys()
1426
        )
1427
        for i, resource_id in enumerate(ordered_resource_ids):
1✔
1428
            resource = resources[resource_id]
1✔
1429
            try:
1✔
1430
                # TODO: cache condition value in resource details on deployment and use cached value here
1431
                if not evaluate_resource_condition(
1✔
1432
                    self.stack.resolved_conditions,
1433
                    resource,
1434
                ):
1435
                    continue
1✔
1436

1437
                executor = self.create_resource_provider_executor()
1✔
1438
                resource_provider_payload = self.create_resource_provider_payload(
1✔
1439
                    "Remove", logical_resource_id=resource_id
1440
                )
1441
                LOG.debug(
1✔
1442
                    'Handling "Remove" for resource "%s" (%s/%s) type "%s"',
1443
                    resource_id,
1444
                    i + 1,
1445
                    len(resources),
1446
                    resource["ResourceType"],
1447
                )
1448
                resource_provider = executor.try_load_resource_provider(get_resource_type(resource))
1✔
1449
                if resource_provider is not None:
1✔
1450
                    event = executor.deploy_loop(
1✔
1451
                        resource_provider, resource, resource_provider_payload
1452
                    )
1453
                else:
1454
                    event = ProgressEvent(OperationStatus.SUCCESS, resource_model={})
1✔
1455
                match event.status:
1✔
1456
                    case OperationStatus.SUCCESS:
1✔
1457
                        self.stack.set_resource_status(resource_id, "DELETE_COMPLETE")
1✔
1458
                    case OperationStatus.PENDING:
1✔
1459
                        # the resource is still being deleted, specifically the provider has
1460
                        # signalled that the deployment loop should skip this resource this
1461
                        # time and come back to it later, likely due to unmet child
1462
                        # resources still existing because we don't delete things in the
1463
                        # correct order yet.
UNCOV
1464
                        continue
×
1465
                    case OperationStatus.FAILED:
1✔
1466
                        LOG.exception(
1✔
1467
                            "Failed to delete resource with id %s. Reason: %s",
1468
                            resource_id,
1469
                            event.message or "unknown",
1470
                        )
UNCOV
1471
                    case OperationStatus.IN_PROGRESS:
×
1472
                        # the resource provider executor should not return this state, so
1473
                        # this state is a programming error
UNCOV
1474
                        raise Exception(
×
1475
                            "Programming error: ResourceProviderExecutor cannot return IN_PROGRESS"
1476
                        )
UNCOV
1477
                    case other_status:
×
UNCOV
1478
                        raise Exception(f"Use of unsupported status found: {other_status}")
×
1479

1480
            except Exception as e:
1✔
1481
                LOG.exception(
1✔
1482
                    "Failed to delete resource with id %s. Final exception: %s",
1483
                    resource_id,
1484
                    e,
1485
                )
1486

1487
        # update status
1488
        self.stack.set_stack_status("DELETE_COMPLETE")
1✔
1489
        self.stack.set_time_attribute("DeletionTime")
1✔
1490

1491
    def do_apply_changes_in_loop(self, changes: list[ChangeConfig], stack: Stack) -> list:
1✔
1492
        # apply changes in a retry loop, to resolve resource dependencies and converge to the target state
1493
        changes_done = []
1✔
1494
        new_resources = stack.resources
1✔
1495

1496
        sorted_changes = order_changes(
1✔
1497
            given_changes=changes,
1498
            resources=new_resources,
1499
            resolved_conditions=stack.resolved_conditions,
1500
            resolved_parameters=stack.resolved_parameters,
1501
        )
1502
        for change_idx, change in enumerate(sorted_changes):
1✔
1503
            res_change = change["ResourceChange"]
1✔
1504
            action = res_change["Action"]
1✔
1505
            is_add_or_modify = action in ["Add", "Modify"]
1✔
1506
            resource_id = res_change["LogicalResourceId"]
1✔
1507

1508
            # TODO: do resolve_refs_recursively once here
1509
            try:
1✔
1510
                if is_add_or_modify:
1✔
1511
                    should_deploy = self.prepare_should_deploy_change(
1✔
1512
                        resource_id, change, stack, new_resources
1513
                    )
1514
                    LOG.debug(
1✔
1515
                        'Handling "%s" for resource "%s" (%s/%s) type "%s" (should_deploy=%s)',
1516
                        action,
1517
                        resource_id,
1518
                        change_idx + 1,
1519
                        len(changes),
1520
                        res_change["ResourceType"],
1521
                        should_deploy,
1522
                    )
1523
                    if not should_deploy:
1✔
1524
                        stack_action = get_action_name_for_resource_change(action)
1✔
1525
                        stack.set_resource_status(resource_id, f"{stack_action}_COMPLETE")
1✔
1526
                        continue
1✔
1527
                elif action == "Remove":
1✔
1528
                    should_remove = self.prepare_should_deploy_change(
1✔
1529
                        resource_id, change, stack, new_resources
1530
                    )
1531
                    if not should_remove:
1✔
UNCOV
1532
                        continue
×
1533
                    LOG.debug(
1✔
1534
                        'Handling "%s" for resource "%s" (%s/%s) type "%s"',
1535
                        action,
1536
                        resource_id,
1537
                        change_idx + 1,
1538
                        len(changes),
1539
                        res_change["ResourceType"],
1540
                    )
1541
                self.apply_change(change, stack=stack)
1✔
1542
                changes_done.append(change)
1✔
1543
            except Exception as e:
1✔
1544
                status_action = {
1✔
1545
                    "Add": "CREATE",
1546
                    "Modify": "UPDATE",
1547
                    "Dynamic": "UPDATE",
1548
                    "Remove": "DELETE",
1549
                }[action]
1550
                stack.add_stack_event(
1✔
1551
                    resource_id=resource_id,
1552
                    physical_res_id=new_resources[resource_id].get("PhysicalResourceId"),
1553
                    status=f"{status_action}_FAILED",
1554
                    status_reason=str(e),
1555
                )
1556
                if config.CFN_VERBOSE_ERRORS:
1✔
UNCOV
1557
                    LOG.exception("Failed to deploy resource %s, stack deploy failed", resource_id)
×
1558
                raise
1✔
1559

1560
        # clean up references to deleted resources in stack
1561
        deletes = [c for c in changes_done if c["ResourceChange"]["Action"] == "Remove"]
1✔
1562
        for delete in deletes:
1✔
1563
            stack.template["Resources"].pop(delete["ResourceChange"]["LogicalResourceId"], None)
1✔
1564

1565
        # resolve outputs
1566
        stack.resolved_outputs = resolve_outputs(self.account_id, self.region_name, stack)
1✔
1567

1568
        return changes_done
1✔
1569

1570

1571
# FIXME: resolve_refs_recursively should not be needed, the resources themselves should have those values available already
1572
def resolve_outputs(account_id: str, region_name: str, stack) -> list[dict]:
1✔
1573
    result = []
1✔
1574
    for k, details in stack.outputs.items():
1✔
1575
        if not evaluate_resource_condition(stack.resolved_conditions, details):
1✔
1576
            continue
1✔
1577
        value = None
1✔
1578
        try:
1✔
1579
            resolve_refs_recursively(
1✔
1580
                account_id,
1581
                region_name,
1582
                stack.stack_name,
1583
                stack.resources,
1584
                stack.mappings,
1585
                stack.resolved_conditions,
1586
                stack.resolved_parameters,
1587
                details,
1588
            )
1589
            value = details["Value"]
1✔
1590
        except Exception as e:
×
1591
            log_method = LOG.debug
×
UNCOV
1592
            if config.CFN_VERBOSE_ERRORS:
×
1593
                raise  # unresolvable outputs cause a stack failure
×
1594
                # log_method = getattr(LOG, "exception")
UNCOV
1595
            log_method("Unable to resolve references in stack outputs: %s - %s", details, e)
×
1596
        exports = details.get("Export") or {}
1✔
1597
        export = exports.get("Name")
1✔
1598
        export = resolve_refs_recursively(
1✔
1599
            account_id,
1600
            region_name,
1601
            stack.stack_name,
1602
            stack.resources,
1603
            stack.mappings,
1604
            stack.resolved_conditions,
1605
            stack.resolved_parameters,
1606
            export,
1607
        )
1608
        description = details.get("Description")
1✔
1609
        entry = {
1✔
1610
            "OutputKey": k,
1611
            "OutputValue": value,
1612
            "Description": description,
1613
            "ExportName": export,
1614
        }
1615
        result.append(entry)
1✔
1616
    return result
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc