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

localstack / localstack / 16766254352

05 Aug 2025 04:40PM UTC coverage: 86.892% (-0.02%) from 86.91%
16766254352

push

github

web-flow
CFNV2: defer deletions for correcting deploy order (#12936)

92 of 99 new or added lines in 6 files covered. (92.93%)

185 existing lines in 21 files now uncovered.

66597 of 76643 relevant lines covered (86.89%)

0.87 hits per line

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

94.54
/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 datetime, timezone
1✔
6
from typing import Final, Optional, Protocol
1✔
7

8
from localstack import config
1✔
9
from localstack.aws.api.cloudformation import (
1✔
10
    ChangeAction,
11
    ResourceStatus,
12
    StackStatus,
13
)
14
from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY
1✔
15
from localstack.services.cloudformation.analytics import track_resource_operation
1✔
16
from localstack.services.cloudformation.deployment_utils import log_not_available_message
1✔
17
from localstack.services.cloudformation.engine.parameters import resolve_ssm_parameter
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
    NodeOutput,
22
    NodeParameter,
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
    parameters: dict
1✔
56
    outputs: dict
1✔
57
    exports: dict
1✔
58

59

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

63

64
class ChangeSetModelExecutor(ChangeSetModelPreproc):
1✔
65
    # TODO: add typing for resolved resources and parameters.
66
    resources: Final[dict[str, ResolvedResource]]
1✔
67
    outputs: Final[dict]
1✔
68
    resolved_parameters: Final[dict]
1✔
69
    exports: Final[dict]
1✔
70
    _deferred_actions: list[DeferredAction]
1✔
71

72
    def __init__(self, change_set: ChangeSet):
1✔
73
        super().__init__(change_set=change_set)
1✔
74
        self.resources = dict()
1✔
75
        self.outputs = dict()
1✔
76
        self.resolved_parameters = dict()
1✔
77
        self.exports = dict()
1✔
78
        self._deferred_actions = list()
1✔
79
        self.resource_provider_executor = ResourceProviderExecutor(
1✔
80
            stack_name=change_set.stack.stack_name,
81
            stack_id=change_set.stack.stack_id,
82
        )
83

84
    def execute(self) -> ChangeSetModelExecutorResult:
1✔
85
        # constructive process
86
        self.process()
1✔
87

88
        if self._deferred_actions:
1✔
89
            self._change_set.stack.set_stack_status(StackStatus.UPDATE_COMPLETE_CLEANUP_IN_PROGRESS)
1✔
90

91
            # perform all deferred actions such as deletions. These must happen in reverse from their
92
            # defined order so that resource dependencies are honoured
93
            # TODO: errors will stop all rollbacks; get parity on this behaviour
94
            for action in self._deferred_actions[::-1]:
1✔
95
                action()
1✔
96

97
        return ChangeSetModelExecutorResult(
1✔
98
            resources=self.resources,
99
            parameters=self.resolved_parameters,
100
            outputs=self.outputs,
101
            exports=self.exports,
102
        )
103

104
    def _defer_action(self, action: DeferredAction):
1✔
105
        self._deferred_actions.append(action)
1✔
106

107
    def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDelta:
1✔
108
        delta = super().visit_node_parameter(node_parameter)
1✔
109

110
        # handle dynamic references, e.g. references to SSM parameters
111
        # TODO: support more parameter types
112
        parameter_type: str = node_parameter.type_.value
1✔
113
        if parameter_type.startswith("AWS::SSM"):
1✔
114
            if parameter_type in [
1✔
115
                "AWS::SSM::Parameter::Value<String>",
116
                "AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>",
117
                "AWS::SSM::Parameter::Value<CommaDelimitedList>",
118
            ]:
119
                delta.after = resolve_ssm_parameter(
1✔
120
                    account_id=self._change_set.account_id,
121
                    region_name=self._change_set.region_name,
122
                    stack_parameter_value=delta.after,
123
                )
124
            else:
UNCOV
125
                raise Exception(f"Unsupported stack parameter type: {parameter_type}")
×
126

127
        self.resolved_parameters[node_parameter.name] = delta.after
1✔
128
        return delta
1✔
129

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

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

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

163
        self._change_set.stack.set_resource_status(
1✔
164
            logical_resource_id=logical_resource_id,
165
            physical_resource_id=self._get_physical_id(logical_resource_id, False),
166
            resource_type=resource_type,
167
            status=ResourceStatus(status),
168
            resource_status_reason=reason,
169
        )
170

171
        if event_status == OperationStatus.FAILED:
1✔
172
            self._change_set.stack.set_stack_status(StackStatus(status))
1✔
173

174
    def _after_deployed_property_value_of(
1✔
175
        self, resource_logical_id: str, property_name: str
176
    ) -> str:
177
        after_resolved_resources = self.resources
1✔
178
        return self._deployed_property_value_of(
1✔
179
            resource_logical_id=resource_logical_id,
180
            property_name=property_name,
181
            resolved_resources=after_resolved_resources,
182
        )
183

184
    def _after_resource_physical_id(self, resource_logical_id: str) -> str:
1✔
185
        after_resolved_resources = self.resources
1✔
186
        return self._resource_physical_resource_id_from(
1✔
187
            logical_resource_id=resource_logical_id, resolved_resources=after_resolved_resources
188
        )
189

190
    def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntityDelta:
1✔
191
        array_identifiers_delta = super().visit_node_depends_on(node_depends_on=node_depends_on)
1✔
192

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

206
        return array_identifiers_delta
1✔
207

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

227
        before = delta.before
1✔
228
        after = delta.after
1✔
229

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

242
        # Update the latest version of this resource for downstream references.
243
        if not is_nothing(after):
1✔
244
            after_logical_id = after.logical_id
1✔
245
            after_physical_id: str = self._after_resource_physical_id(
1✔
246
                resource_logical_id=after_logical_id
247
            )
248
            after.physical_resource_id = after_physical_id
1✔
249
        return delta
1✔
250

251
    def visit_node_output(
1✔
252
        self, node_output: NodeOutput
253
    ) -> PreprocEntityDelta[PreprocOutput, PreprocOutput]:
254
        delta = super().visit_node_output(node_output=node_output)
1✔
255
        after = delta.after
1✔
256
        if is_nothing(after) or (isinstance(after, PreprocOutput) and after.condition is False):
1✔
257
            return delta
1✔
258

259
        # TODO validate export name duplication in same template and all exports
260
        if delta.after.export:
1✔
261
            self.exports[delta.after.export.get("Name")] = delta.after.value
1✔
262

263
        self.outputs[delta.after.name] = delta.after.value
1✔
264
        return delta
1✔
265

266
    def _execute_resource_change(
1✔
267
        self, name: str, before: Optional[PreprocResource], after: Optional[PreprocResource]
268
    ) -> None:
269
        # Changes are to be made about this resource.
270
        # TODO: this logic is a POC and should be revised.
271
        if not is_nothing(before) and not is_nothing(after):
1✔
272
            # Case: change on same type.
273
            if before.resource_type == after.resource_type:
1✔
274
                # Register a Modified if changed.
275
                # XXX hacky, stick the previous resources' properties into the payload
276
                before_properties = self._merge_before_properties(name, before)
1✔
277

278
                self._process_event(ChangeAction.Modify, name, OperationStatus.IN_PROGRESS)
1✔
279
                if after.requires_replacement:
1✔
280
                    event = self._execute_resource_action(
1✔
281
                        action=ChangeAction.Add,
282
                        logical_resource_id=name,
283
                        resource_type=before.resource_type,
284
                        before_properties=None,
285
                        after_properties=after.properties,
286
                    )
287
                    self._process_event(
1✔
288
                        ChangeAction.Modify,
289
                        name,
290
                        event.status,
291
                        reason=event.message,
292
                        resource_type=before.resource_type,
293
                    )
294

295
                    def cleanup():
1✔
296
                        self._process_event(ChangeAction.Remove, name, OperationStatus.IN_PROGRESS)
1✔
297
                        event = self._execute_resource_action(
1✔
298
                            action=ChangeAction.Remove,
299
                            logical_resource_id=name,
300
                            resource_type=before.resource_type,
301
                            before_properties=before_properties,
302
                            after_properties=None,
303
                        )
304
                        self._process_event(
1✔
305
                            ChangeAction.Remove,
306
                            name,
307
                            event.status,
308
                            reason=event.message,
309
                            resource_type=before.resource_type,
310
                        )
311

312
                    self._defer_action(cleanup)
1✔
313
                else:
314
                    event = self._execute_resource_action(
1✔
315
                        action=ChangeAction.Modify,
316
                        logical_resource_id=name,
317
                        resource_type=before.resource_type,
318
                        before_properties=before_properties,
319
                        after_properties=after.properties,
320
                    )
321
                    self._process_event(
1✔
322
                        ChangeAction.Modify,
323
                        name,
324
                        event.status,
325
                        reason=event.message,
326
                        resource_type=before.resource_type,
327
                    )
328
            # Case: type migration.
329
            # TODO: Add test to assert that on type change the resources are replaced.
330
            else:
331
                # XXX hacky, stick the previous resources' properties into the payload
UNCOV
332
                before_properties = self._merge_before_properties(name, before)
×
333
                # Register a Removed for the previous type.
334

NEW
335
                def perform_deletion():
×
NEW
336
                    event = self._execute_resource_action(
×
337
                        action=ChangeAction.Remove,
338
                        logical_resource_id=name,
339
                        resource_type=before.resource_type,
340
                        before_properties=before_properties,
341
                        after_properties=None,
342
                    )
NEW
343
                    self._process_event(
×
344
                        ChangeAction.Modify,
345
                        name,
346
                        event.status,
347
                        reason=event.message,
348
                        resource_type=before.resource_type,
349
                    )
350

NEW
351
                self._defer_action(perform_deletion)
×
352

UNCOV
353
                event = self._execute_resource_action(
×
354
                    action=ChangeAction.Add,
355
                    logical_resource_id=name,
356
                    resource_type=after.resource_type,
357
                    before_properties=None,
358
                    after_properties=after.properties,
359
                )
UNCOV
360
                self._process_event(
×
361
                    ChangeAction.Modify,
362
                    name,
363
                    event.status,
364
                    reason=event.message,
365
                    resource_type=before.resource_type,
366
                )
367
        elif not is_nothing(before):
1✔
368
            # Case: removal
369
            # XXX hacky, stick the previous resources' properties into the payload
370
            # XXX hacky, stick the previous resources' properties into the payload
371
            before_properties = self._merge_before_properties(name, before)
1✔
372

373
            def perform_deletion():
1✔
374
                self._process_event(
1✔
375
                    ChangeAction.Remove,
376
                    name,
377
                    OperationStatus.IN_PROGRESS,
378
                    resource_type=before.resource_type,
379
                )
380
                event = self._execute_resource_action(
1✔
381
                    action=ChangeAction.Remove,
382
                    logical_resource_id=name,
383
                    resource_type=before.resource_type,
384
                    before_properties=before_properties,
385
                    after_properties=None,
386
                )
387
                self._process_event(
1✔
388
                    ChangeAction.Remove,
389
                    name,
390
                    event.status,
391
                    reason=event.message,
392
                    resource_type=before.resource_type,
393
                )
394

395
            self._defer_action(perform_deletion)
1✔
396
        elif not is_nothing(after):
1✔
397
            # Case: addition
398
            self._process_event(
1✔
399
                ChangeAction.Add,
400
                name,
401
                OperationStatus.IN_PROGRESS,
402
                resource_type=after.resource_type,
403
            )
404
            event = self._execute_resource_action(
1✔
405
                action=ChangeAction.Add,
406
                logical_resource_id=name,
407
                resource_type=after.resource_type,
408
                before_properties=None,
409
                after_properties=after.properties,
410
            )
411
            self._process_event(
1✔
412
                ChangeAction.Add,
413
                name,
414
                event.status,
415
                reason=event.message,
416
                resource_type=after.resource_type,
417
            )
418

419
    def _merge_before_properties(
1✔
420
        self, name: str, preproc_resource: PreprocResource
421
    ) -> PreprocProperties:
422
        if previous_resource_properties := self._change_set.stack.resolved_resources.get(
1✔
423
            name, {}
424
        ).get("Properties"):
425
            return PreprocProperties(properties=previous_resource_properties)
1✔
426

427
        # XXX fall back to returning the input value
428
        return copy.deepcopy(preproc_resource.properties)
1✔
429

430
    def _execute_resource_action(
1✔
431
        self,
432
        action: ChangeAction,
433
        logical_resource_id: str,
434
        resource_type: str,
435
        before_properties: Optional[PreprocProperties],
436
        after_properties: Optional[PreprocProperties],
437
    ) -> ProgressEvent:
438
        LOG.debug("Executing resource action: %s for resource '%s'", action, logical_resource_id)
1✔
439
        payload = self.create_resource_provider_payload(
1✔
440
            action=action,
441
            logical_resource_id=logical_resource_id,
442
            resource_type=resource_type,
443
            before_properties=before_properties,
444
            after_properties=after_properties,
445
        )
446
        resource_provider = self.resource_provider_executor.try_load_resource_provider(
1✔
447
            resource_type
448
        )
449
        track_resource_operation(action, resource_type, missing=resource_provider is not None)
1✔
450

451
        extra_resource_properties = {}
1✔
452
        if resource_provider is not None:
1✔
453
            try:
1✔
454
                event = self.resource_provider_executor.deploy_loop(
1✔
455
                    resource_provider, extra_resource_properties, payload
456
                )
457
            except Exception as e:
1✔
458
                reason = str(e)
1✔
459
                LOG.warning(
1✔
460
                    "Resource provider operation failed: '%s'",
461
                    reason,
462
                    exc_info=LOG.isEnabledFor(logging.DEBUG),
463
                )
464
                event = ProgressEvent(
1✔
465
                    OperationStatus.FAILED,
466
                    resource_model={},
467
                    message=f"Resource provider operation failed: {reason}",
468
                )
469
        elif config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
1✔
470
            log_not_available_message(
1✔
471
                resource_type,
472
                f'No resource provider found for "{resource_type}"',
473
            )
474
            LOG.warning(
1✔
475
                "Deployment of resource type %s successful due to config CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES"
476
            )
477
            event = ProgressEvent(
1✔
478
                OperationStatus.SUCCESS,
479
                resource_model={},
480
                message=f"Resource type {resource_type} is not supported but was deployed as a fallback",
481
            )
482
        else:
UNCOV
483
            log_not_available_message(
×
484
                resource_type,
485
                f'No resource provider found for "{resource_type}"',
486
            )
UNCOV
487
            event = ProgressEvent(
×
488
                OperationStatus.FAILED,
489
                resource_model={},
490
                message=f"Resource type {resource_type} not supported",
491
            )
492

493
        match event.status:
1✔
494
            case OperationStatus.SUCCESS:
1✔
495
                # merge the resources state with the external state
496
                # TODO: this is likely a duplicate of updating from extra_resource_properties
497

498
                # TODO: add typing
499
                # TODO: avoid the use of string literals for sampling from the object, use typed classes instead
500
                # TODO: avoid sampling from resources and use tmp var reference
501
                # TODO: add utils functions to abstract this logic away (resource.update(..))
502
                # TODO: avoid the use of setdefault (debuggability/readability)
503
                # TODO: review the use of merge
504

505
                # Don't update the resolved resources if we have deleted that resource
506
                if action != ChangeAction.Remove:
1✔
507
                    status_from_action = EventOperationFromAction[action.value]
1✔
508
                    physical_resource_id = (
1✔
509
                        extra_resource_properties["PhysicalResourceId"]
510
                        if resource_provider
511
                        else MOCKED_REFERENCE
512
                    )
513
                    resolved_resource = ResolvedResource(
1✔
514
                        Properties=event.resource_model,
515
                        LogicalResourceId=logical_resource_id,
516
                        Type=resource_type,
517
                        LastUpdatedTimestamp=datetime.now(timezone.utc),
518
                        ResourceStatus=ResourceStatus(f"{status_from_action}_COMPLETE"),
519
                        PhysicalResourceId=physical_resource_id,
520
                    )
521
                    # TODO: do we actually need this line?
522
                    resolved_resource.update(extra_resource_properties)
1✔
523

524
                    self.resources[logical_resource_id] = resolved_resource
1✔
525

526
            case OperationStatus.FAILED:
1✔
527
                reason = event.message
1✔
528
                LOG.warning(
1✔
529
                    "Resource provider operation failed: '%s'",
530
                    reason,
531
                )
UNCOV
532
            case other:
×
533
                raise NotImplementedError(f"Event status '{other}' not handled")
534
        return event
1✔
535

536
    def create_resource_provider_payload(
1✔
537
        self,
538
        action: ChangeAction,
539
        logical_resource_id: str,
540
        resource_type: str,
541
        before_properties: Optional[PreprocProperties],
542
        after_properties: Optional[PreprocProperties],
543
    ) -> Optional[ResourceProviderPayload]:
544
        # FIXME: use proper credentials
545
        creds: Credentials = {
1✔
546
            "accessKeyId": self._change_set.stack.account_id,
547
            "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY,
548
            "sessionToken": "",
549
        }
550
        before_properties_value = before_properties.properties if before_properties else None
1✔
551
        after_properties_value = after_properties.properties if after_properties else None
1✔
552

553
        match action:
1✔
554
            case ChangeAction.Add:
1✔
555
                resource_properties = after_properties_value or {}
1✔
556
                previous_resource_properties = None
1✔
557
            case ChangeAction.Modify | ChangeAction.Dynamic:
1✔
558
                resource_properties = after_properties_value or {}
1✔
559
                previous_resource_properties = before_properties_value or {}
1✔
560
            case ChangeAction.Remove:
1✔
561
                resource_properties = before_properties_value or {}
1✔
562
                # previous_resource_properties = None
563
                # HACK: our providers use a mix of `desired_state` and `previous_state` so ensure the payload is present for both
564
                previous_resource_properties = resource_properties
1✔
UNCOV
565
            case _:
×
566
                raise NotImplementedError(f"Action '{action}' not handled")
567

568
        resource_provider_payload: ResourceProviderPayload = {
1✔
569
            "awsAccountId": self._change_set.stack.account_id,
570
            "callbackContext": {},
571
            "stackId": self._change_set.stack.stack_name,
572
            "resourceType": resource_type,
573
            "resourceTypeVersion": "000000",
574
            # TODO: not actually a UUID
575
            "bearerToken": str(uuid.uuid4()),
576
            "region": self._change_set.stack.region_name,
577
            "action": str(action),
578
            "requestData": {
579
                "logicalResourceId": logical_resource_id,
580
                "resourceProperties": resource_properties,
581
                "previousResourceProperties": previous_resource_properties,
582
                "callerCredentials": creds,
583
                "providerCredentials": creds,
584
                "systemTags": {},
585
                "previousSystemTags": {},
586
                "stackTags": {},
587
                "previousStackTags": {},
588
            },
589
        }
590
        return resource_provider_payload
1✔
591

592
    @staticmethod
1✔
593
    def _replace_url_outputs_if_required(value: str) -> str:
1✔
594
        api_match = REGEX_OUTPUT_APIGATEWAY.match(value)
1✔
595
        if api_match and value not in config.CFN_STRING_REPLACEMENT_DENY_LIST:
1✔
596
            prefix = api_match[1]
1✔
597
            host = api_match[2]
1✔
598
            path = api_match[3]
1✔
599
            port = localstack_host().port
1✔
600
            value = f"{prefix}{host}:{port}/{path}"
1✔
601
            return value
1✔
602

603
        return value
1✔
604

605
    def visit_terminal_value_created(
1✔
606
        self, value: TerminalValueCreated
607
    ) -> PreprocEntityDelta[str, str]:
608
        if isinstance(value.value, str):
1✔
609
            after = self._replace_url_outputs_if_required(value.value)
1✔
610
        else:
611
            after = value.value
1✔
612
        return PreprocEntityDelta(after=after)
1✔
613

614
    def visit_terminal_value_modified(
1✔
615
        self, value: TerminalValueModified
616
    ) -> PreprocEntityDelta[str, str]:
617
        # we only need to transform the after
618
        if isinstance(value.modified_value, str):
1✔
619
            after = self._replace_url_outputs_if_required(value.modified_value)
1✔
620
        else:
621
            after = value.modified_value
1✔
622
        return PreprocEntityDelta(before=value.value, after=after)
1✔
623

624
    def visit_terminal_value_unchanged(
1✔
625
        self, terminal_value_unchanged: TerminalValueUnchanged
626
    ) -> PreprocEntityDelta:
627
        if isinstance(terminal_value_unchanged.value, str):
1✔
628
            value = self._replace_url_outputs_if_required(terminal_value_unchanged.value)
1✔
629
        else:
630
            value = terminal_value_unchanged.value
1✔
631
        return PreprocEntityDelta(before=value, after=value)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc