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

localstack / localstack / 17115498067

20 Aug 2025 09:05PM UTC coverage: 86.876% (-0.01%) from 86.889%
17115498067

push

github

simonrw
Handle parameter conversions for different transforms

13 of 13 new or added lines in 2 files covered. (100.0%)

42 existing lines in 7 files now uncovered.

67023 of 77148 relevant lines covered (86.88%)

0.87 hits per line

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

91.94
/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_transform.py
1
import copy
1✔
2
import json
1✔
3
import logging
1✔
4
import os
1✔
5
import re
1✔
6
from typing import Any, Final, TypedDict
1✔
7

8
import boto3
1✔
9
import jsonpath_ng
1✔
10
from botocore.exceptions import ClientError
1✔
11
from samtranslator.translator.transform import transform as transform_sam
1✔
12

13
from localstack.aws.connect import connect_to
1✔
14
from localstack.services.cloudformation.engine.parameters import StackParameter
1✔
15
from localstack.services.cloudformation.engine.policy_loader import create_policy_loader
1✔
16
from localstack.services.cloudformation.engine.template_preparer import parse_template
1✔
17
from localstack.services.cloudformation.engine.transformers import (
1✔
18
    FailedTransformationException,
19
    ResolveRefsRecursivelyContext,
20
    apply_language_extensions_transform,
21
)
22
from localstack.services.cloudformation.engine.v2.change_set_model import (
1✔
23
    ChangeType,
24
    FnTransform,
25
    Maybe,
26
    NodeGlobalTransform,
27
    NodeIntrinsicFunction,
28
    NodeIntrinsicFunctionFnTransform,
29
    NodeProperties,
30
    NodeResource,
31
    NodeResources,
32
    NodeTransform,
33
    Nothing,
34
    Scope,
35
    is_nothing,
36
)
37
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import (
1✔
38
    ChangeSetModelPreproc,
39
    PreprocEntityDelta,
40
    PreprocProperties,
41
)
42
from localstack.services.cloudformation.engine.validations import ValidationError
1✔
43
from localstack.services.cloudformation.stores import get_cloudformation_store
1✔
44
from localstack.services.cloudformation.v2.entities import ChangeSet
1✔
45
from localstack.services.cloudformation.v2.types import EngineParameter, engine_parameter_value
1✔
46
from localstack.utils import testutil
1✔
47
from localstack.utils.strings import long_uid
1✔
48

49
LOG = logging.getLogger(__name__)
1✔
50

51
SERVERLESS_TRANSFORM = "AWS::Serverless-2016-10-31"
1✔
52
EXTENSIONS_TRANSFORM = "AWS::LanguageExtensions"
1✔
53
SECRETSMANAGER_TRANSFORM = "AWS::SecretsManager-2020-07-23"
1✔
54
INCLUDE_TRANSFORM = "AWS::Include"
1✔
55

56
_SCOPE_TRANSFORM_TEMPLATE_OUTCOME: Final[Scope] = Scope("TRANSFORM_TEMPLATE_OUTCOME")
1✔
57

58

59
def engine_parameters_to_stack_parameters(
1✔
60
    engine_parameters: dict[str, EngineParameter],
61
) -> dict[str, StackParameter]:
62
    out = {}
1✔
63
    for name, engine_param in engine_parameters.items():
1✔
64
        out[name] = StackParameter(
1✔
65
            ParameterKey=name,
66
            ParameterValue=engine_parameter_value(engine_param),
67
            ResolvedValue=engine_param.get("resolved_value"),
68
            ParameterType=engine_param["type_"],
69
        )
70
    return out
1✔
71

72

73
# TODO: evaluate the use of subtypes to represent and validate types of transforms
74
class GlobalTransform:
1✔
75
    name: str
1✔
76
    parameters: Maybe[dict]
1✔
77

78
    def __init__(self, name: str, parameters: Maybe[dict]):
1✔
79
        self.name = name
1✔
80
        self.parameters = parameters
1✔
81

82

83
class TransformPreprocParameter(TypedDict):
1✔
84
    # TODO: expand
85
    ParameterKey: str
1✔
86
    ParameterValue: Any
1✔
87
    ParameterType: str | None
1✔
88

89

90
class ChangeSetModelTransform(ChangeSetModelPreproc):
1✔
91
    _before_parameters: Final[dict[str, EngineParameter] | None]
1✔
92
    _after_parameters: Final[dict[str, EngineParameter] | None]
1✔
93
    _before_template: Final[Maybe[dict]]
1✔
94
    _after_template: Final[Maybe[dict]]
1✔
95

96
    def __init__(
1✔
97
        self,
98
        change_set: ChangeSet,
99
        before_parameters: dict,
100
        after_parameters: dict,
101
        before_template: dict | None,
102
        after_template: dict | None,
103
    ):
104
        super().__init__(change_set=change_set)
1✔
105
        self._before_parameters = before_parameters
1✔
106
        self._after_parameters = after_parameters
1✔
107
        self._before_template = before_template or Nothing
1✔
108
        self._after_template = after_template or Nothing
1✔
109

110
    def transform(self) -> tuple[dict, dict]:
1✔
111
        self._setup_runtime_cache()
1✔
112
        self._execute_local_transforms()
1✔
113
        transformed_before_template, transformed_after_template = self._execute_global_transforms()
1✔
114
        self._save_runtime_cache()
1✔
115

116
        return transformed_before_template, transformed_after_template
1✔
117

118
    # Ported from v1:
119
    @staticmethod
1✔
120
    def _apply_global_serverless_transformation(
1✔
121
        region_name: str, template: dict, parameters: dict
122
    ) -> dict:
123
        """only returns string when parsing SAM template, otherwise None"""
124
        # TODO: we might also want to override the access key ID to account ID
125
        region_before = os.environ.get("AWS_DEFAULT_REGION")
1✔
126
        if boto3.session.Session().region_name is None:
1✔
127
            os.environ["AWS_DEFAULT_REGION"] = region_name
1✔
128
        loader = create_policy_loader()
1✔
129
        # The following transformation function can carry out in-place changes ensure this cannot occur.
130
        template = copy.deepcopy(template)
1✔
131
        parameters = copy.deepcopy(parameters)
1✔
132
        try:
1✔
133
            transformed = transform_sam(template, parameters, loader)
1✔
134
            return transformed
1✔
135
        except Exception as e:
×
UNCOV
136
            raise FailedTransformationException(transformation=SERVERLESS_TRANSFORM, message=str(e))
×
137
        finally:
138
            # Note: we need to fix boto3 region, otherwise AWS SAM transformer fails
139
            os.environ.pop("AWS_DEFAULT_REGION", None)
1✔
140
            if region_before is not None:
1✔
UNCOV
141
                os.environ["AWS_DEFAULT_REGION"] = region_before
×
142

143
    def _compute_include_transform(self, parameters: dict, fragment: dict) -> dict:
1✔
144
        location = parameters.get("Location")
1✔
145
        if not location or not location.startswith("s3://"):
1✔
146
            raise FailedTransformationException(
×
147
                transformation=INCLUDE_TRANSFORM,
148
                message="Unexpected Location parameter for AWS::Include transformer: %s" % location,
149
            )
150

151
        s3_client = connect_to(
1✔
152
            aws_access_key_id=self._change_set.account_id, region_name=self._change_set.region_name
153
        ).s3
154
        bucket, _, path = location.removeprefix("s3://").partition("/")
1✔
155
        try:
1✔
156
            content = testutil.download_s3_object(s3_client, bucket, path)
1✔
UNCOV
157
        except ClientError:
×
UNCOV
158
            raise FailedTransformationException(
×
159
                transformation=INCLUDE_TRANSFORM,
160
                message="Error downloading S3 object '%s/%s'" % (bucket, path),
161
            )
162
        try:
1✔
163
            template_to_include = parse_template(content)
1✔
UNCOV
164
        except Exception as e:
×
UNCOV
165
            raise FailedTransformationException(transformation=INCLUDE_TRANSFORM, message=str(e))
×
166

167
        return {**fragment, **template_to_include}
1✔
168

169
    def _apply_global_transform(
1✔
170
        self,
171
        global_transform: GlobalTransform,
172
        template: dict,
173
        parameters: dict[str, EngineParameter],
174
    ) -> dict:
175
        transform_name = global_transform.name
1✔
176
        if transform_name == EXTENSIONS_TRANSFORM:
1✔
177
            resources = template["Resources"]
1✔
178
            mappings = template.get("Mappings", {})
1✔
179
            conditions = template.get("Conditions", {})
1✔
180

181
            resolve_context = ResolveRefsRecursivelyContext(
1✔
182
                self._change_set.account_id,
183
                self._change_set.region_name,
184
                self._change_set.stack.stack_name,
185
                resources,
186
                mappings,
187
                conditions,
188
                parameters=engine_parameters_to_stack_parameters(parameters),
189
            )
190
            transformed_template = apply_language_extensions_transform(template, resolve_context)
1✔
191
        elif transform_name == SERVERLESS_TRANSFORM:
1✔
192
            # serverless transform just requires the key/value pairs
193
            serverless_parameters = {}
1✔
194
            for name, param in parameters.items():
1✔
195
                serverless_parameters[name] = param.get("resolved_value") or engine_parameter_value(
1✔
196
                    param
197
                )
198
            transformed_template = self._apply_global_serverless_transformation(
1✔
199
                region_name=self._change_set.region_name,
200
                template=template,
201
                parameters=serverless_parameters,
202
            )
203
        elif transform_name == SECRETSMANAGER_TRANSFORM:
1✔
204
            # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/transform-aws-secretsmanager.html
UNCOV
205
            LOG.warning("%s is not yet supported. Ignoring.", SECRETSMANAGER_TRANSFORM)
×
UNCOV
206
            transformed_template = template
×
207
        elif transform_name == INCLUDE_TRANSFORM:
1✔
208
            transformed_template = self._compute_include_transform(
1✔
209
                parameters=global_transform.parameters,
210
                fragment=template,
211
            )
212
        else:
213
            transformed_template = self._invoke_macro(
1✔
214
                name=global_transform.name,
215
                parameters=global_transform.parameters
216
                if not is_nothing(global_transform.parameters)
217
                else {},
218
                fragment=template,
219
                allow_string=False,
220
            )
221
        return transformed_template
1✔
222

223
    def _execute_local_transforms(self):
1✔
224
        node_template = self._change_set.update_model.node_template
1✔
225
        self.visit_node_resources(node_template.resources)
1✔
226

227
    def _execute_global_transforms(self) -> tuple[dict, dict]:
1✔
228
        node_template = self._change_set.update_model.node_template
1✔
229

230
        transform_delta: PreprocEntityDelta[list[GlobalTransform], list[GlobalTransform]] = (
1✔
231
            self.visit_node_transform(node_template.transform)
232
        )
233
        transform_before: Maybe[list[GlobalTransform]] = transform_delta.before
1✔
234
        transform_after: Maybe[list[GlobalTransform]] = transform_delta.after
1✔
235

236
        transformed_before_template = self._before_template
1✔
237
        if transform_before and not is_nothing(self._before_template):
1✔
238
            if _SCOPE_TRANSFORM_TEMPLATE_OUTCOME in self._before_cache:
1✔
239
                transformed_before_template = self._before_cache[_SCOPE_TRANSFORM_TEMPLATE_OUTCOME]
1✔
240
            else:
241
                for before_global_transform in transform_before:
1✔
242
                    if not is_nothing(before_global_transform.name):
1✔
UNCOV
243
                        transformed_before_template = self._apply_global_transform(
×
244
                            global_transform=before_global_transform,
245
                            parameters=self._before_parameters,
246
                            template=transformed_before_template,
247
                        )
248

249
                # Macro transformations won't remove the transform from the template
250
                if "Transform" in transformed_before_template:
1✔
UNCOV
251
                    transformed_before_template.pop("Transform")
×
252
                self._before_cache[_SCOPE_TRANSFORM_TEMPLATE_OUTCOME] = transformed_before_template
1✔
253

254
        transformed_after_template = self._after_template
1✔
255
        if transform_after and not is_nothing(self._after_template):
1✔
256
            transformed_after_template = self._after_template
1✔
257
            for after_global_transform in transform_after:
1✔
258
                if not is_nothing(after_global_transform.name):
1✔
259
                    transformed_after_template = self._apply_global_transform(
1✔
260
                        global_transform=after_global_transform,
261
                        parameters=self._after_parameters,
262
                        template=transformed_after_template,
263
                    )
264
            # Macro transformations won't remove the transform from the template
265
            if "Transform" in transformed_after_template:
1✔
266
                transformed_after_template.pop("Transform")
1✔
267
            self._after_cache[_SCOPE_TRANSFORM_TEMPLATE_OUTCOME] = transformed_after_template
1✔
268

269
        return transformed_before_template, transformed_after_template
1✔
270

271
    def visit_node_global_transform(
1✔
272
        self, node_global_transform: NodeGlobalTransform
273
    ) -> PreprocEntityDelta[GlobalTransform, GlobalTransform]:
274
        change_type = node_global_transform.change_type
1✔
275

276
        name_delta = self.visit(node_global_transform.name)
1✔
277
        parameters_delta = self.visit(node_global_transform.parameters)
1✔
278

279
        before = Nothing
1✔
280
        if change_type != ChangeType.CREATED:
1✔
281
            before = GlobalTransform(name=name_delta.before, parameters=parameters_delta.before)
1✔
282
        after = Nothing
1✔
283
        if change_type != ChangeType.REMOVED:
1✔
284
            after = GlobalTransform(name=name_delta.after, parameters=parameters_delta.after)
1✔
285
        return PreprocEntityDelta(before=before, after=after)
1✔
286

287
    def visit_node_transform(
1✔
288
        self, node_transform: NodeTransform
289
    ) -> PreprocEntityDelta[list[GlobalTransform], list[GlobalTransform]]:
290
        change_type = node_transform.change_type
1✔
291
        before = [] if change_type != ChangeType.CREATED else Nothing
1✔
292
        after = [] if change_type != ChangeType.REMOVED else Nothing
1✔
293
        for change_set_entity in node_transform.global_transforms:
1✔
294
            if not isinstance(change_set_entity.name.value, str):
1✔
295
                raise ValidationError("Key Name of transform definition must be a string.")
1✔
296

297
            delta: PreprocEntityDelta[GlobalTransform, GlobalTransform] = self.visit(
1✔
298
                change_set_entity=change_set_entity
299
            )
300
            delta_before = delta.before
1✔
301
            delta_after = delta.after
1✔
302
            if not is_nothing(before) and not is_nothing(delta_before):
1✔
303
                before.append(delta_before)
1✔
304
            if not is_nothing(after) and not is_nothing(delta_after):
1✔
305
                after.append(delta_after)
1✔
306
        return PreprocEntityDelta(before=before, after=after)
1✔
307

308
    def _compute_fn_transform(
1✔
309
        self, macro_definition: Any, siblings: Any, allow_string: False
310
    ) -> Any:
311
        def _normalize_transform(obj):
1✔
312
            transforms = []
1✔
313

314
            if isinstance(obj, str):
1✔
315
                transforms.append({"Name": obj, "Parameters": {}})
1✔
316

317
            if isinstance(obj, dict):
1✔
318
                transforms.append(obj)
1✔
319

320
            if isinstance(obj, list):
1✔
321
                for v in obj:
1✔
322
                    if isinstance(v, str):
1✔
UNCOV
323
                        transforms.append({"Name": v, "Parameters": {}})
×
324

325
                    if isinstance(v, dict):
1✔
326
                        if not v.get("Parameters"):
1✔
UNCOV
327
                            v["Parameters"] = {}
×
328
                        transforms.append(v)
1✔
329

330
            return transforms
1✔
331

332
        normalized_transforms = _normalize_transform(macro_definition)
1✔
333
        transform_output = copy.deepcopy(siblings)
1✔
334
        for transform in normalized_transforms:
1✔
335
            transform_name = transform["Name"]
1✔
336
            if transform_name == INCLUDE_TRANSFORM:
1✔
337
                transform_output = self._compute_include_transform(
1✔
338
                    parameters=transform["Parameters"], fragment=transform_output
339
                )
340
            else:
341
                transform_output: dict | str = self._invoke_macro(
1✔
342
                    fragment=transform_output,
343
                    name=transform["Name"],
344
                    parameters=transform.get("Parameters", {}),
345
                    allow_string=allow_string,
346
                )
347

348
        if isinstance(transform_output, dict) and FnTransform in transform_output:
1✔
349
            transform_output.pop(FnTransform)
1✔
350

351
        return transform_output
1✔
352

353
    def _replace_at_jsonpath(self, template: dict, path: str, result: Any):
1✔
354
        pattern = jsonpath_ng.parse(path)
1✔
355
        result_template = pattern.update(template, result)
1✔
356

357
        return result_template
1✔
358

359
    def visit_node_intrinsic_function_fn_transform(
1✔
360
        self, node_intrinsic_function: NodeIntrinsicFunctionFnTransform
361
    ) -> PreprocEntityDelta:
362
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
363
        parent_json_path = node_intrinsic_function.scope.parent.jsonpath
1✔
364

365
        # Only when a FnTransform is used as Property value the macro function is allowed to return a str
366
        property_value_regex = r"\.(Properties)"
1✔
367
        allow_string = False
1✔
368
        if re.search(property_value_regex, parent_json_path):
1✔
369
            allow_string = True
1✔
370

371
        if not is_nothing(arguments_delta.before):
1✔
UNCOV
372
            before = self._compute_fn_transform(
×
373
                arguments_delta.before,
374
                node_intrinsic_function.before_siblings,
375
                allow_string=allow_string,
376
            )
UNCOV
377
            updated_before_template = self._replace_at_jsonpath(
×
378
                self._before_template, parent_json_path, before
379
            )
UNCOV
380
            self._after_cache[_SCOPE_TRANSFORM_TEMPLATE_OUTCOME] = updated_before_template
×
381
        else:
382
            before = Nothing
1✔
383

384
        if not is_nothing(arguments_delta.after):
1✔
385
            after = self._compute_fn_transform(
1✔
386
                arguments_delta.after,
387
                node_intrinsic_function.after_siblings,
388
                allow_string=allow_string,
389
            )
390
            updated_after_template = self._replace_at_jsonpath(
1✔
391
                self._after_template, parent_json_path, after
392
            )
393
            self._after_cache[_SCOPE_TRANSFORM_TEMPLATE_OUTCOME] = updated_after_template
1✔
394
        else:
UNCOV
395
            after = Nothing
×
396

397
        self._save_runtime_cache()
1✔
398
        return PreprocEntityDelta(before=before, after=after)
1✔
399

400
    def visit_node_properties(
1✔
401
        self, node_properties: NodeProperties
402
    ) -> PreprocEntityDelta[PreprocProperties, PreprocProperties]:
403
        if not is_nothing(node_properties.fn_transform):
1✔
404
            self.visit_node_intrinsic_function_fn_transform(node_properties.fn_transform)
1✔
405

406
        return super().visit_node_properties(node_properties=node_properties)
1✔
407

408
    def visit_node_resource(self, node_resource: NodeResource) -> PreprocEntityDelta:
1✔
409
        if not is_nothing(node_resource.fn_transform):
1✔
410
            self.visit_node_intrinsic_function_fn_transform(
1✔
411
                node_intrinsic_function=node_resource.fn_transform
412
            )
413

414
        return super().visit_node_resource(node_resource)
1✔
415

416
    def visit_node_resources(self, node_resources: NodeResources) -> PreprocEntityDelta:
1✔
417
        if not is_nothing(node_resources.fn_transform):
1✔
418
            self.visit_node_intrinsic_function_fn_transform(
1✔
419
                node_intrinsic_function=node_resources.fn_transform
420
            )
421

422
        return super().visit_node_resources(node_resources=node_resources)
1✔
423

424
    def _invoke_macro(self, name: str, parameters: dict, fragment: dict, allow_string=False):
1✔
425
        account_id = self._change_set.account_id
1✔
426
        region_name = self._change_set.region_name
1✔
427
        macro_definition = get_cloudformation_store(
1✔
428
            account_id=account_id, region_name=region_name
429
        ).macros.get(name)
430

431
        if not macro_definition:
1✔
UNCOV
432
            raise FailedTransformationException(name, f"Transformation {name} is not supported.")
×
433

434
        simplified_parameters = {}
1✔
435
        if resolved_parameters := self._change_set.resolved_parameters:
1✔
436
            for key, resolved_parameter in resolved_parameters.items():
1✔
437
                final_value = engine_parameter_value(resolved_parameter)
1✔
438
                simplified_parameters[key] = (
1✔
439
                    final_value.split(",")
440
                    if resolved_parameter["type_"] == "CommaDelimitedList"
441
                    else final_value
442
                )
443

444
        transformation_id = f"{account_id}::{name}"
1✔
445
        event = {
1✔
446
            "region": region_name,
447
            "accountId": account_id,
448
            "fragment": fragment,
449
            "transformId": transformation_id,
450
            "params": parameters,
451
            "requestId": long_uid(),
452
            "templateParameterValues": simplified_parameters,
453
        }
454

455
        client = connect_to(aws_access_key_id=account_id, region_name=region_name).lambda_
1✔
456
        try:
1✔
457
            invocation = client.invoke(
1✔
458
                FunctionName=macro_definition["FunctionName"], Payload=json.dumps(event)
459
            )
UNCOV
460
        except ClientError:
×
UNCOV
461
            LOG.error(
×
462
                "client error executing lambda function '%s' with payload '%s'",
463
                macro_definition["FunctionName"],
464
                json.dumps(event),
465
            )
UNCOV
466
            raise
×
467
        if invocation.get("StatusCode") != 200 or invocation.get("FunctionError") == "Unhandled":
1✔
468
            raise FailedTransformationException(
1✔
469
                transformation=name,
470
                message=f"Received malformed response from transform {transformation_id}. Rollback requested by user.",
471
            )
472
        result = json.loads(invocation["Payload"].read())
1✔
473

474
        if result.get("status") != "success":
1✔
475
            error_message = result.get("errorMessage")
1✔
476
            message = (
1✔
477
                f"Transform {transformation_id} failed with: {error_message}. Rollback requested by user."
478
                if error_message
479
                else f"Transform {transformation_id} failed without an error message.. Rollback requested by user."
480
            )
481
            raise FailedTransformationException(transformation=name, message=message)
1✔
482

483
        if not isinstance(result.get("fragment"), dict) and not allow_string:
1✔
484
            raise FailedTransformationException(
1✔
485
                transformation=name,
486
                message="Template format error: unsupported structure.. Rollback requested by user.",
487
            )
488

489
        return result.get("fragment")
1✔
490

491
    def visit_node_intrinsic_function_fn_get_att(
1✔
492
        self, node_intrinsic_function: NodeIntrinsicFunction
493
    ) -> PreprocEntityDelta:
494
        return self.visit(node_intrinsic_function.arguments)
1✔
495

496
    def visit_node_intrinsic_function_fn_sub(
1✔
497
        self, node_intrinsic_function: NodeIntrinsicFunction
498
    ) -> PreprocEntityDelta:
499
        try:
1✔
500
            # If an argument is a Parameter it should be resolved, any other case, ignore it
501
            return super().visit_node_intrinsic_function_fn_sub(node_intrinsic_function)
1✔
502
        except RuntimeError:
1✔
503
            return self.visit(node_intrinsic_function.arguments)
1✔
504

505
    def visit_node_intrinsic_function_fn_split(
1✔
506
        self, node_intrinsic_function: NodeIntrinsicFunction
507
    ) -> PreprocEntityDelta:
508
        try:
1✔
509
            # If an argument is a Parameter it should be resolved, any other case, ignore it
510
            return super().visit_node_intrinsic_function_fn_split(node_intrinsic_function)
1✔
511
        except RuntimeError:
1✔
512
            return self.visit(node_intrinsic_function.arguments)
1✔
513

514
    def visit_node_intrinsic_function_fn_select(
1✔
515
        self, node_intrinsic_function: NodeIntrinsicFunction
516
    ) -> PreprocEntityDelta:
517
        try:
1✔
518
            # If an argument is a Parameter it should be resolved, any other case, ignore it
519
            return super().visit_node_intrinsic_function_fn_select(node_intrinsic_function)
1✔
520
        except RuntimeError:
1✔
521
            return self.visit(node_intrinsic_function.arguments)
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