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

localstack / localstack / 17144436094

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

push

github

web-flow
APIGW: internalize DeleteIntegrationResponse (#13046)

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

235 existing lines in 11 files now uncovered.

67068 of 77229 relevant lines covered (86.84%)

0.87 hits per line

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

91.79
/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py
1
import copy
1✔
2
import logging
1✔
3
import uuid
1✔
4
from dataclasses import dataclass
1✔
5
from datetime import UTC, datetime
1✔
6
from typing import Final, Protocol
1✔
7

8
from localstack import config
1✔
9
from localstack.aws.api.cloudformation import (
1✔
10
    ChangeAction,
11
    Output,
12
    ResourceStatus,
13
    StackStatus,
14
)
15
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
1✔
16
from localstack.services.cloudformation.analytics import track_resource_operation
1✔
17
from localstack.services.cloudformation.deployment_utils import log_not_available_message
1✔
18
from localstack.services.cloudformation.engine.template_deployer import REGEX_OUTPUT_APIGATEWAY
1✔
19
from localstack.services.cloudformation.engine.v2.change_set_model import (
1✔
20
    NodeDependsOn,
21
    NodeIntrinsicFunction,
22
    NodeOutput,
23
    NodeResource,
24
    TerminalValueCreated,
25
    TerminalValueModified,
26
    TerminalValueUnchanged,
27
    is_nothing,
28
)
29
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import (
1✔
30
    MOCKED_REFERENCE,
31
    ChangeSetModelPreproc,
32
    PreprocEntityDelta,
33
    PreprocOutput,
34
    PreprocProperties,
35
    PreprocResource,
36
)
37
from localstack.services.cloudformation.resource_provider import (
1✔
38
    Credentials,
39
    OperationStatus,
40
    ProgressEvent,
41
    ResourceProviderExecutor,
42
    ResourceProviderPayload,
43
)
44
from localstack.services.cloudformation.v2.entities import ChangeSet, ResolvedResource
1✔
45
from localstack.utils.urls import localstack_host
1✔
46

47
LOG = logging.getLogger(__name__)
1✔
48

49
EventOperationFromAction = {"Add": "CREATE", "Modify": "UPDATE", "Remove": "DELETE"}
1✔
50

51

52
@dataclass
1✔
53
class ChangeSetModelExecutorResult:
1✔
54
    resources: dict[str, ResolvedResource]
1✔
55
    outputs: list[Output]
1✔
56
    failure_message: str | None = None
1✔
57

58

59
class DeferredAction(Protocol):
1✔
60
    def __call__(self) -> None: ...
1✔
61

62

63
@dataclass
1✔
64
class Deferred:
1✔
65
    name: str
1✔
66
    action: DeferredAction
1✔
67

68

69
class TriggerRollback(Exception):
1✔
70
    """
71
    Sentinel exception to signal that the deployment should be stopped for a reason
72
    """
73

74
    def __init__(self, logical_resource_id: str, reason: str | None):
1✔
75
        self.logical_resource_id = logical_resource_id
1✔
76
        self.reason = reason
1✔
77

78

79
class ChangeSetModelExecutor(ChangeSetModelPreproc):
1✔
80
    # TODO: add typing for resolved resources and parameters.
81
    resources: Final[dict[str, ResolvedResource]]
1✔
82
    outputs: Final[list[Output]]
1✔
83
    _deferred_actions: list[Deferred]
1✔
84

85
    def __init__(self, change_set: ChangeSet):
1✔
86
        super().__init__(change_set=change_set)
1✔
87
        self.resources = {}
1✔
88
        self.outputs = []
1✔
89
        self._deferred_actions = []
1✔
90
        self.resource_provider_executor = ResourceProviderExecutor(
1✔
91
            stack_name=change_set.stack.stack_name,
92
            stack_id=change_set.stack.stack_id,
93
        )
94

95
    def execute(self) -> ChangeSetModelExecutorResult:
1✔
96
        # constructive process
97
        failure_message = None
1✔
98
        try:
1✔
99
            self.process()
1✔
100
        except TriggerRollback as e:
1✔
101
            failure_message = e.reason
1✔
UNCOV
102
        except Exception as e:
×
UNCOV
103
            failure_message = str(e)
×
104

105
        if self._deferred_actions:
1✔
106
            if failure_message:
1✔
107
                # TODO: differentiate between update and create
UNCOV
108
                self._change_set.stack.set_stack_status(StackStatus.ROLLBACK_IN_PROGRESS)
×
109
            else:
110
                # TODO: correct status
111
                self._change_set.stack.set_stack_status(
1✔
112
                    StackStatus.UPDATE_COMPLETE_CLEANUP_IN_PROGRESS
113
                )
114

115
            # perform all deferred actions such as deletions. These must happen in reverse from their
116
            # defined order so that resource dependencies are honoured
117
            # TODO: errors will stop all rollbacks; get parity on this behaviour
118
            for deferred in self._deferred_actions[::-1]:
1✔
119
                LOG.debug("executing deferred action: '%s'", deferred.name)
1✔
120
                deferred.action()
1✔
121

122
        if failure_message:
1✔
123
            # TODO: differentiate between update and create
124
            self._change_set.stack.set_stack_status(StackStatus.ROLLBACK_COMPLETE)
1✔
125

126
        return ChangeSetModelExecutorResult(
1✔
127
            resources=self.resources, outputs=self.outputs, failure_message=failure_message
128
        )
129

130
    def _defer_action(self, name: str, action: DeferredAction):
1✔
131
        self._deferred_actions.append(Deferred(name=name, action=action))
1✔
132

133
    def _get_physical_id(self, logical_resource_id, strict: bool = True) -> str | None:
1✔
134
        physical_resource_id = None
1✔
135
        try:
1✔
136
            physical_resource_id = self._after_resource_physical_id(logical_resource_id)
1✔
UNCOV
137
        except RuntimeError:
×
138
            # The physical id is missing or is set to None, which is invalid.
UNCOV
139
            pass
×
140
        if physical_resource_id is None:
1✔
141
            # The physical resource id is None after an update that didn't rewrite the resource, the previous
142
            # resource id is therefore the current physical id of this resource.
143

144
            try:
1✔
145
                physical_resource_id = self._before_resource_physical_id(logical_resource_id)
1✔
UNCOV
146
            except RuntimeError as e:
×
UNCOV
147
                if strict:
×
UNCOV
148
                    raise e
×
149
        return physical_resource_id
1✔
150

151
    def _process_event(
1✔
152
        self,
153
        *,
154
        action: ChangeAction,
155
        logical_resource_id,
156
        event_status: OperationStatus,
157
        resource_type: str,
158
        special_action: str = None,
159
        reason: str = None,
160
    ):
161
        status_from_action = special_action or EventOperationFromAction[action.value]
1✔
162
        if event_status == OperationStatus.SUCCESS:
1✔
163
            status = f"{status_from_action}_COMPLETE"
1✔
164
        else:
165
            status = f"{status_from_action}_{event_status.name}"
1✔
166

167
        physical_resource_id = self._get_physical_id(logical_resource_id, False)
1✔
168
        self._change_set.stack.set_resource_status(
1✔
169
            logical_resource_id=logical_resource_id,
170
            physical_resource_id=physical_resource_id,
171
            resource_type=resource_type,
172
            status=ResourceStatus(status),
173
            resource_status_reason=reason,
174
        )
175

176
        if event_status == OperationStatus.FAILED:
1✔
177
            self._change_set.stack.set_stack_status(StackStatus(status))
1✔
178

179
    def _after_deployed_property_value_of(
1✔
180
        self, resource_logical_id: str, property_name: str
181
    ) -> str:
182
        after_resolved_resources = self.resources
1✔
183
        return self._deployed_property_value_of(
1✔
184
            resource_logical_id=resource_logical_id,
185
            property_name=property_name,
186
            resolved_resources=after_resolved_resources,
187
        )
188

189
    def _after_resource_physical_id(self, resource_logical_id: str) -> str:
1✔
190
        after_resolved_resources = self.resources
1✔
191
        return self._resource_physical_resource_id_from(
1✔
192
            logical_resource_id=resource_logical_id, resolved_resources=after_resolved_resources
193
        )
194

195
    def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntityDelta:
1✔
196
        array_identifiers_delta = super().visit_node_depends_on(node_depends_on=node_depends_on)
1✔
197

198
        # Visit depends_on resources before returning.
199
        depends_on_resource_logical_ids: set[str] = set()
1✔
200
        if array_identifiers_delta.before:
1✔
201
            depends_on_resource_logical_ids.update(array_identifiers_delta.before)
1✔
202
        if array_identifiers_delta.after:
1✔
203
            depends_on_resource_logical_ids.update(array_identifiers_delta.after)
1✔
204
        for depends_on_resource_logical_id in depends_on_resource_logical_ids:
1✔
205
            node_resource = self._get_node_resource_for(
1✔
206
                resource_name=depends_on_resource_logical_id,
207
                node_template=self._change_set.update_model.node_template,
208
            )
209
            self.visit(node_resource)
1✔
210

211
        return array_identifiers_delta
1✔
212

213
    def visit_node_resource(
1✔
214
        self, node_resource: NodeResource
215
    ) -> PreprocEntityDelta[PreprocResource, PreprocResource]:
216
        """
217
        Overrides the default preprocessing for NodeResource objects by annotating the
218
        `after` delta with the physical resource ID, if side effects resulted in an update.
219
        """
220
        try:
1✔
221
            delta = super().visit_node_resource(node_resource=node_resource)
1✔
UNCOV
222
        except Exception as e:
×
UNCOV
223
            self._process_event(
×
224
                action=node_resource.change_type.to_change_action(),
225
                logical_resource_id=node_resource.name,
226
                event_status=OperationStatus.FAILED,
227
                resource_type=node_resource.type_.value,
228
                reason=str(e),
229
            )
UNCOV
230
            raise e
×
231

232
        before = delta.before
1✔
233
        after = delta.after
1✔
234

235
        if before != after:
1✔
236
            # There are changes for this resource.
237
            self._execute_resource_change(name=node_resource.name, before=before, after=after)
1✔
238
        else:
239
            # There are no updates for this resource; iff the resource was previously
240
            # deployed, then the resolved details are copied in the current state for
241
            # references or other downstream operations.
242
            if not is_nothing(before):
1✔
243
                before_logical_id = delta.before.logical_id
1✔
244
                before_resource = self._before_resolved_resources.get(before_logical_id, {})
1✔
245
                self.resources[before_logical_id] = before_resource
1✔
246

247
        # Update the latest version of this resource for downstream references.
248
        if not is_nothing(after):
1✔
249
            after_logical_id = after.logical_id
1✔
250
            resource = self.resources[after_logical_id]
1✔
251
            resource_failed_to_deploy = resource["ResourceStatus"] in {
1✔
252
                ResourceStatus.CREATE_FAILED,
253
                ResourceStatus.UPDATE_FAILED,
254
            }
255
            if not resource_failed_to_deploy:
1✔
256
                after_physical_id: str = self._after_resource_physical_id(
1✔
257
                    resource_logical_id=after_logical_id
258
                )
259
                after.physical_resource_id = after_physical_id
1✔
260
            after.status = resource["ResourceStatus"]
1✔
261

262
            # terminate the deployment process
263
            if resource_failed_to_deploy:
1✔
264
                raise TriggerRollback(
1✔
265
                    logical_resource_id=after_logical_id,
266
                    reason=resource.get("ResourceStatusReason"),
267
                )
268
        return delta
1✔
269

270
    def visit_node_output(
1✔
271
        self, node_output: NodeOutput
272
    ) -> PreprocEntityDelta[PreprocOutput, PreprocOutput]:
273
        delta = super().visit_node_output(node_output=node_output)
1✔
274
        after = delta.after
1✔
275
        if is_nothing(after) or (isinstance(after, PreprocOutput) and after.condition is False):
1✔
276
            return delta
1✔
277

278
        output = Output(
1✔
279
            OutputKey=delta.after.name,
280
            OutputValue=delta.after.value,
281
            # TODO
282
            # Description=delta.after.description
283
        )
284
        if after.export:
1✔
285
            output["ExportName"] = after.export["Name"]
1✔
286
        self.outputs.append(output)
1✔
287
        return delta
1✔
288

289
    def _execute_resource_change(
1✔
290
        self, name: str, before: PreprocResource | None, after: PreprocResource | None
291
    ) -> None:
292
        # Changes are to be made about this resource.
293
        # TODO: this logic is a POC and should be revised.
294
        if not is_nothing(before) and not is_nothing(after):
1✔
295
            # Case: change on same type.
296
            if before.resource_type == after.resource_type:
1✔
297
                # Register a Modified if changed.
298
                # XXX hacky, stick the previous resources' properties into the payload
299
                before_properties = self._merge_before_properties(name, before)
1✔
300

301
                self._process_event(
1✔
302
                    action=ChangeAction.Modify,
303
                    logical_resource_id=name,
304
                    event_status=OperationStatus.IN_PROGRESS,
305
                    resource_type=before.resource_type,
306
                )
307
                if after.requires_replacement:
1✔
308
                    event = self._execute_resource_action(
1✔
309
                        action=ChangeAction.Add,
310
                        logical_resource_id=name,
311
                        resource_type=before.resource_type,
312
                        before_properties=None,
313
                        after_properties=after.properties,
314
                    )
315
                    self._process_event(
1✔
316
                        action=ChangeAction.Modify,
317
                        logical_resource_id=name,
318
                        event_status=event.status,
319
                        resource_type=before.resource_type,
320
                        reason=event.message,
321
                    )
322

323
                    def cleanup():
1✔
324
                        self._process_event(
1✔
325
                            action=ChangeAction.Remove,
326
                            logical_resource_id=name,
327
                            event_status=OperationStatus.IN_PROGRESS,
328
                            resource_type=before.resource_type,
329
                        )
330
                        event = self._execute_resource_action(
1✔
331
                            action=ChangeAction.Remove,
332
                            logical_resource_id=name,
333
                            resource_type=before.resource_type,
334
                            before_properties=before_properties,
335
                            after_properties=None,
336
                            part_of_replacement=True,
337
                        )
338
                        self._process_event(
1✔
339
                            action=ChangeAction.Remove,
340
                            logical_resource_id=name,
341
                            event_status=event.status,
342
                            resource_type=before.resource_type,
343
                            reason=event.message,
344
                        )
345

346
                    self._defer_action(f"cleanup-from-replacement-{name}", cleanup)
1✔
347
                else:
348
                    event = self._execute_resource_action(
1✔
349
                        action=ChangeAction.Modify,
350
                        logical_resource_id=name,
351
                        resource_type=before.resource_type,
352
                        before_properties=before_properties,
353
                        after_properties=after.properties,
354
                    )
355
                    self._process_event(
1✔
356
                        action=ChangeAction.Modify,
357
                        logical_resource_id=name,
358
                        event_status=event.status,
359
                        resource_type=before.resource_type,
360
                        reason=event.message,
361
                    )
362
            # Case: type migration.
363
            # TODO: Add test to assert that on type change the resources are replaced.
364
            else:
365
                # XXX hacky, stick the previous resources' properties into the payload
UNCOV
366
                before_properties = self._merge_before_properties(name, before)
×
367
                # Register a Removed for the previous type.
368

UNCOV
369
                def perform_deletion():
×
UNCOV
370
                    event = self._execute_resource_action(
×
371
                        action=ChangeAction.Remove,
372
                        logical_resource_id=name,
373
                        resource_type=before.resource_type,
374
                        before_properties=before_properties,
375
                        after_properties=None,
376
                    )
UNCOV
377
                    self._process_event(
×
378
                        action=ChangeAction.Modify,
379
                        logical_resource_id=name,
380
                        event_status=event.status,
381
                        resource_type=before.resource_type,
382
                        reason=event.message,
383
                    )
384

UNCOV
385
                self._defer_action(f"type-migration-{name}", perform_deletion)
×
386

UNCOV
387
                event = self._execute_resource_action(
×
388
                    action=ChangeAction.Add,
389
                    logical_resource_id=name,
390
                    resource_type=after.resource_type,
391
                    before_properties=None,
392
                    after_properties=after.properties,
393
                )
UNCOV
394
                self._process_event(
×
395
                    action=ChangeAction.Modify,
396
                    logical_resource_id=name,
397
                    event_status=event.status,
398
                    resource_type=before.resource_type,
399
                    reason=event.message,
400
                )
401
        elif not is_nothing(before):
1✔
402
            # Case: removal
403
            # XXX hacky, stick the previous resources' properties into the payload
404
            # XXX hacky, stick the previous resources' properties into the payload
405
            before_properties = self._merge_before_properties(name, before)
1✔
406

407
            def perform_deletion():
1✔
408
                self._process_event(
1✔
409
                    action=ChangeAction.Remove,
410
                    logical_resource_id=name,
411
                    resource_type=before.resource_type,
412
                    event_status=OperationStatus.IN_PROGRESS,
413
                )
414
                event = self._execute_resource_action(
1✔
415
                    action=ChangeAction.Remove,
416
                    logical_resource_id=name,
417
                    resource_type=before.resource_type,
418
                    before_properties=before_properties,
419
                    after_properties=None,
420
                )
421
                self._process_event(
1✔
422
                    action=ChangeAction.Remove,
423
                    logical_resource_id=name,
424
                    event_status=event.status,
425
                    resource_type=before.resource_type,
426
                    reason=event.message,
427
                )
428

429
            self._defer_action(f"remove-{name}", perform_deletion)
1✔
430
        elif not is_nothing(after):
1✔
431
            # Case: addition
432
            self._process_event(
1✔
433
                action=ChangeAction.Add,
434
                logical_resource_id=name,
435
                event_status=OperationStatus.IN_PROGRESS,
436
                resource_type=after.resource_type,
437
            )
438
            event = self._execute_resource_action(
1✔
439
                action=ChangeAction.Add,
440
                logical_resource_id=name,
441
                resource_type=after.resource_type,
442
                before_properties=None,
443
                after_properties=after.properties,
444
            )
445
            self._process_event(
1✔
446
                action=ChangeAction.Add,
447
                logical_resource_id=name,
448
                event_status=event.status,
449
                resource_type=after.resource_type,
450
                reason=event.message,
451
            )
452

453
    def _merge_before_properties(
1✔
454
        self, name: str, preproc_resource: PreprocResource
455
    ) -> PreprocProperties:
456
        if previous_resource_properties := self._change_set.stack.resolved_resources.get(
1✔
457
            name, {}
458
        ).get("Properties"):
459
            return PreprocProperties(properties=previous_resource_properties)
1✔
460

461
        # XXX fall back to returning the input value
462
        return copy.deepcopy(preproc_resource.properties)
1✔
463

464
    def _execute_resource_action(
1✔
465
        self,
466
        action: ChangeAction,
467
        logical_resource_id: str,
468
        resource_type: str,
469
        before_properties: PreprocProperties | None,
470
        after_properties: PreprocProperties | None,
471
        part_of_replacement: bool = False,
472
    ) -> ProgressEvent:
473
        LOG.debug("Executing resource action: %s for resource '%s'", action, logical_resource_id)
1✔
474
        payload = self.create_resource_provider_payload(
1✔
475
            action=action,
476
            logical_resource_id=logical_resource_id,
477
            resource_type=resource_type,
478
            before_properties=before_properties,
479
            after_properties=after_properties,
480
        )
481
        resource_provider = self.resource_provider_executor.try_load_resource_provider(
1✔
482
            resource_type
483
        )
484
        track_resource_operation(action, resource_type, missing=resource_provider is not None)
1✔
485

486
        extra_resource_properties = {}
1✔
487
        if resource_provider is not None:
1✔
488
            try:
1✔
489
                event = self.resource_provider_executor.deploy_loop(
1✔
490
                    resource_provider, extra_resource_properties, payload
491
                )
492
            except Exception as e:
1✔
493
                reason = str(e)
1✔
494
                LOG.warning(
1✔
495
                    "Resource provider operation failed: '%s'",
496
                    reason,
497
                    exc_info=LOG.isEnabledFor(logging.DEBUG) and config.CFN_VERBOSE_ERRORS,
498
                )
499
                event = ProgressEvent(
1✔
500
                    OperationStatus.FAILED,
501
                    resource_model={},
502
                    message=f"Resource provider operation failed: {reason}",
503
                )
504
        elif config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
1✔
505
            log_not_available_message(
1✔
506
                resource_type,
507
                f'No resource provider found for "{resource_type}"',
508
            )
509
            LOG.warning(
1✔
510
                "Deployment of resource type %s successful due to config CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES"
511
            )
512
            event = ProgressEvent(
1✔
513
                OperationStatus.SUCCESS,
514
                resource_model={},
515
                message=f"Resource type {resource_type} is not supported but was deployed as a fallback",
516
            )
517
        else:
UNCOV
518
            log_not_available_message(
×
519
                resource_type,
520
                f'No resource provider found for "{resource_type}"',
521
            )
UNCOV
522
            event = ProgressEvent(
×
523
                OperationStatus.FAILED,
524
                resource_model={},
525
                message=f"Resource type {resource_type} not supported",
526
            )
527

528
        if part_of_replacement and action == ChangeAction.Remove:
1✔
529
            # Early return as we don't want to update internal state of the executor if this is a
530
            # cleanup of an old resource. The new resource has already been created and the state
531
            # updated
532
            return event
1✔
533

534
        status_from_action = EventOperationFromAction[action.value]
1✔
535
        resolved_resource = ResolvedResource(
1✔
536
            Properties=event.resource_model,
537
            LogicalResourceId=logical_resource_id,
538
            Type=resource_type,
539
            LastUpdatedTimestamp=datetime.now(UTC),
540
        )
541
        match event.status:
1✔
542
            case OperationStatus.SUCCESS:
1✔
543
                # merge the resources state with the external state
544
                # TODO: this is likely a duplicate of updating from extra_resource_properties
545

546
                # TODO: add typing
547
                # TODO: avoid the use of string literals for sampling from the object, use typed classes instead
548
                # TODO: avoid sampling from resources and use tmp var reference
549
                # TODO: add utils functions to abstract this logic away (resource.update(..))
550
                # TODO: avoid the use of setdefault (debuggability/readability)
551
                # TODO: review the use of merge
552

553
                # Don't update the resolved resources if we have deleted that resource
554
                if action != ChangeAction.Remove:
1✔
555
                    physical_resource_id = (
1✔
556
                        extra_resource_properties["PhysicalResourceId"]
557
                        if resource_provider
558
                        else MOCKED_REFERENCE
559
                    )
560
                    resolved_resource["PhysicalResourceId"] = physical_resource_id
1✔
561
                    resolved_resource["ResourceStatus"] = ResourceStatus(
1✔
562
                        f"{status_from_action}_COMPLETE"
563
                    )
564
                    # TODO: do we actually need this line?
565
                    resolved_resource.update(extra_resource_properties)
1✔
566

567
            case OperationStatus.FAILED:
1✔
568
                reason = event.message
1✔
569
                LOG.warning(
1✔
570
                    "Resource provider operation failed: '%s'",
571
                    reason,
572
                )
573
                resolved_resource["ResourceStatus"] = ResourceStatus(f"{status_from_action}_FAILED")
1✔
574
                resolved_resource["ResourceStatusReason"] = reason
1✔
UNCOV
575
            case other:
×
576
                raise NotImplementedError(f"Event status '{other}' not handled")
577

578
        self.resources[logical_resource_id] = resolved_resource
1✔
579
        return event
1✔
580

581
    def create_resource_provider_payload(
1✔
582
        self,
583
        action: ChangeAction,
584
        logical_resource_id: str,
585
        resource_type: str,
586
        before_properties: PreprocProperties | None,
587
        after_properties: PreprocProperties | None,
588
    ) -> ResourceProviderPayload | None:
589
        # FIXME: use proper credentials
590
        creds: Credentials = {
1✔
591
            "accessKeyId": self._change_set.stack.account_id,
592
            "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY,
593
            "sessionToken": "",
594
        }
595
        before_properties_value = before_properties.properties if before_properties else None
1✔
596
        after_properties_value = after_properties.properties if after_properties else None
1✔
597

598
        match action:
1✔
599
            case ChangeAction.Add:
1✔
600
                resource_properties = after_properties_value or {}
1✔
601
                previous_resource_properties = None
1✔
602
            case ChangeAction.Modify | ChangeAction.Dynamic:
1✔
603
                resource_properties = after_properties_value or {}
1✔
604
                previous_resource_properties = before_properties_value or {}
1✔
605
            case ChangeAction.Remove:
1✔
606
                resource_properties = before_properties_value or {}
1✔
607
                # previous_resource_properties = None
608
                # HACK: our providers use a mix of `desired_state` and `previous_state` so ensure the payload is present for both
609
                previous_resource_properties = resource_properties
1✔
UNCOV
610
            case _:
×
611
                raise NotImplementedError(f"Action '{action}' not handled")
612

613
        resource_provider_payload: ResourceProviderPayload = {
1✔
614
            "awsAccountId": self._change_set.stack.account_id,
615
            "callbackContext": {},
616
            "stackId": self._change_set.stack.stack_name,
617
            "resourceType": resource_type,
618
            "resourceTypeVersion": "000000",
619
            # TODO: not actually a UUID
620
            "bearerToken": str(uuid.uuid4()),
621
            "region": self._change_set.stack.region_name,
622
            "action": str(action),
623
            "requestData": {
624
                "logicalResourceId": logical_resource_id,
625
                "resourceProperties": resource_properties,
626
                "previousResourceProperties": previous_resource_properties,
627
                "callerCredentials": creds,
628
                "providerCredentials": creds,
629
                "systemTags": {},
630
                "previousSystemTags": {},
631
                "stackTags": {},
632
                "previousStackTags": {},
633
            },
634
        }
635
        return resource_provider_payload
1✔
636

637
    @staticmethod
1✔
638
    def _replace_url_outputs_if_required(value: str) -> str:
1✔
639
        api_match = REGEX_OUTPUT_APIGATEWAY.match(value)
1✔
640
        if api_match and value not in config.CFN_STRING_REPLACEMENT_DENY_LIST:
1✔
641
            prefix = api_match[1]
1✔
642
            host = api_match[2]
1✔
643
            path = api_match[3]
1✔
644
            port = localstack_host().port
1✔
645
            value = f"{prefix}{host}:{port}/{path}"
1✔
646
            return value
1✔
647

648
        return value
1✔
649

650
    def _replace_url_outputs_in_delta_if_required(
1✔
651
        self, delta: PreprocEntityDelta
652
    ) -> PreprocEntityDelta:
653
        if isinstance(delta.before, str):
1✔
654
            delta.before = self._replace_url_outputs_if_required(delta.before)
1✔
655
        if isinstance(delta.after, str):
1✔
656
            delta.after = self._replace_url_outputs_if_required(delta.after)
1✔
657
        return delta
1✔
658

659
    def visit_terminal_value_created(
1✔
660
        self, value: TerminalValueCreated
661
    ) -> PreprocEntityDelta[str, str]:
662
        if isinstance(value.value, str):
1✔
663
            after = self._replace_url_outputs_if_required(value.value)
1✔
664
        else:
665
            after = value.value
1✔
666
        return PreprocEntityDelta(after=after)
1✔
667

668
    def visit_terminal_value_modified(
1✔
669
        self, value: TerminalValueModified
670
    ) -> PreprocEntityDelta[str, str]:
671
        # we only need to transform the after
672
        if isinstance(value.modified_value, str):
1✔
673
            after = self._replace_url_outputs_if_required(value.modified_value)
1✔
674
        else:
675
            after = value.modified_value
1✔
676
        return PreprocEntityDelta(before=value.value, after=after)
1✔
677

678
    def visit_terminal_value_unchanged(
1✔
679
        self, terminal_value_unchanged: TerminalValueUnchanged
680
    ) -> PreprocEntityDelta:
681
        if isinstance(terminal_value_unchanged.value, str):
1✔
682
            value = self._replace_url_outputs_if_required(terminal_value_unchanged.value)
1✔
683
        else:
684
            value = terminal_value_unchanged.value
1✔
685
        return PreprocEntityDelta(before=value, after=value)
1✔
686

687
    def visit_node_intrinsic_function_fn_join(
1✔
688
        self, node_intrinsic_function: NodeIntrinsicFunction
689
    ) -> PreprocEntityDelta:
690
        delta = super().visit_node_intrinsic_function_fn_join(node_intrinsic_function)
1✔
691
        return self._replace_url_outputs_in_delta_if_required(delta)
1✔
692

693
    def visit_node_intrinsic_function_fn_sub(
1✔
694
        self, node_intrinsic_function: NodeIntrinsicFunction
695
    ) -> PreprocEntityDelta:
696
        delta = super().visit_node_intrinsic_function_fn_sub(node_intrinsic_function)
1✔
697
        return self._replace_url_outputs_in_delta_if_required(delta)
1✔
698

699
    # TODO: other intrinsic functions
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc