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

SwissDataScienceCenter / renku-data-services / 20339056754

18 Dec 2025 01:46PM UTC coverage: 86.009%. First build
20339056754

Pull #1128

github

web-flow
Merge fe560b56c into c5f960729
Pull Request #1128: feat: set lastInteraction time via session patch and return willHibernateAt

84 of 96 new or added lines in 11 files covered. (87.5%)

24037 of 27947 relevant lines covered (86.01%)

1.51 hits per line

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

78.77
/components/renku_data_services/crc/core.py
1
"""crc modules converters and validators."""
2

3
from typing import Literal, overload
2✔
4
from urllib.parse import urlparse
2✔
5

6
from ulid import ULID
2✔
7

8
from renku_data_services.base_models import RESET, ResetType
2✔
9
from renku_data_services.crc import apispec, models
2✔
10
from renku_data_services.errors import errors
2✔
11

12

13
def validate_quota(body: apispec.QuotaWithOptionalId) -> models.UnsavedQuota:
2✔
14
    """Validate a quota object."""
15
    return models.UnsavedQuota(
2✔
16
        cpu=body.cpu,
17
        memory=body.memory,
18
        gpu=body.gpu,
19
    )
20

21

22
def validate_quota_put_patch(body: apispec.QuotaWithId | apispec.QuotaPatch) -> models.QuotaPatch:
2✔
23
    """Validate the put request for a quota."""
24
    return models.QuotaPatch(
2✔
25
        cpu=body.cpu,
26
        memory=body.memory,
27
        gpu=body.gpu,
28
    )
29

30

31
def validate_resource_class(body: apispec.ResourceClass) -> models.UnsavedResourceClass:
2✔
32
    """Validate a resource class object."""
33
    if len(body.name) > 40:
2✔
34
        # TODO: Should this be added to the API spec instead?
35
        raise errors.ValidationError(message="'name' cannot be longer than 40 characters.")
×
36
    if body.default_storage > body.max_storage:
2✔
37
        raise errors.ValidationError(message="The default storage cannot be larger than the max allowable storage.")
×
38
    # We need to sort node affinities and tolerations to make '__eq__' reliable
39
    node_affinities = sorted(
2✔
40
        (
41
            models.NodeAffinity(key=na.key, required_during_scheduling=na.required_during_scheduling)
42
            for na in body.node_affinities or []
43
        ),
44
        key=lambda x: (x.key, x.required_during_scheduling),
45
    )
46
    tolerations = sorted(t.root for t in body.tolerations or [])
2✔
47
    return models.UnsavedResourceClass(
2✔
48
        name=body.name,
49
        cpu=body.cpu,
50
        memory=body.memory,
51
        max_storage=body.max_storage,
52
        gpu=body.gpu,
53
        default=body.default,
54
        default_storage=body.default_storage,
55
        node_affinities=node_affinities,
56
        tolerations=tolerations,
57
    )
58

59

60
@overload
2✔
61
def validate_resource_class_patch_or_put(
2✔
62
    body: apispec.ResourceClassPatch | apispec.ResourceClass, method: Literal["PATCH", "PUT"]
63
) -> models.ResourceClassPatch: ...
64
@overload
2✔
65
def validate_resource_class_patch_or_put(
2✔
66
    body: apispec.ResourceClassPatchWithId | apispec.ResourceClassWithId, method: Literal["PATCH", "PUT"]
67
) -> models.ResourceClassPatchWithId: ...
68
def validate_resource_class_patch_or_put(
2✔
69
    body: apispec.ResourceClassPatch
70
    | apispec.ResourceClassPatchWithId
71
    | apispec.ResourceClass
72
    | apispec.ResourceClassWithId,
73
    method: Literal["PATCH", "PUT"],
74
) -> models.ResourceClassPatch | models.ResourceClassPatchWithId:
75
    """Validate the patch to a resource class."""
76
    rc_id = body.id if isinstance(body, (apispec.ResourceClassPatchWithId, apispec.ResourceClassWithId)) else None
2✔
77
    node_affinities: list[models.NodeAffinity] | None = [] if method == "PUT" else None
2✔
78
    if body.node_affinities:
2✔
79
        node_affinities = sorted(
2✔
80
            (
81
                models.NodeAffinity(key=na.key, required_during_scheduling=na.required_during_scheduling)
82
                for na in body.node_affinities or []
83
            ),
84
            key=lambda x: (x.key, x.required_during_scheduling),
85
        )
86
    tolerations: list[str] | None = [] if method == "PUT" else None
2✔
87
    if body.tolerations:
2✔
88
        tolerations = sorted(t.root for t in body.tolerations or [])
2✔
89
    if rc_id:
2✔
90
        return models.ResourceClassPatchWithId(
2✔
91
            id=rc_id,
92
            name=body.name,
93
            cpu=body.cpu,
94
            memory=body.memory,
95
            max_storage=body.max_storage,
96
            gpu=body.gpu,
97
            default=body.default,
98
            default_storage=body.default_storage,
99
            node_affinities=node_affinities,
100
            tolerations=tolerations,
101
        )
102
    return models.ResourceClassPatch(
2✔
103
        name=body.name,
104
        cpu=body.cpu,
105
        memory=body.memory,
106
        max_storage=body.max_storage,
107
        gpu=body.gpu,
108
        default=body.default,
109
        default_storage=body.default_storage,
110
        node_affinities=node_affinities,
111
        tolerations=tolerations,
112
    )
113

114

115
def validate_resource_class_update(
2✔
116
    existing: models.ResourceClass,
117
    update: models.ResourceClassPatch,
118
) -> None:
119
    """Validate the update to a resource class."""
120
    name = update.name if update.name is not None else existing.name
1✔
121
    max_storage = update.max_storage if update.max_storage is not None else existing.max_storage
1✔
122
    default_storage = update.default_storage if update.default_storage is not None else existing.default_storage
1✔
123

124
    if update.default is not None and existing.default != update.default:
1✔
125
        raise errors.ValidationError(message="Changing the default class in a resource pool is not supported.")
×
126

127
    if len(name) > 40:
1✔
128
        # TODO: Should this be added to the API spec instead?
129
        raise errors.ValidationError(message="'name' cannot be longer than 40 characters.")
×
130
    if default_storage > max_storage:
1✔
131
        raise errors.ValidationError(message="The default storage cannot be larger than the max allowable storage.")
×
132

133

134
def validate_resource_pool_post(body: apispec.ResourcePool) -> models.UnsavedResourcePool:
2✔
135
    """Validate a resource pool object."""
136
    if len(body.name) > 40:
2✔
137
        # TODO: Should this be added to the API spec instead?
138
        raise errors.ValidationError(message="'name' cannot be longer than 40 characters.")
×
139
    if body.default and not body.public:
2✔
140
        raise errors.ValidationError(message="The default resource pool has to be public.")
1✔
141
    if body.default and body.quota is not None:
2✔
142
        raise errors.ValidationError(message="A default resource pool cannot have a quota.")
×
143
    if body.remote and body.default:
2✔
144
        raise errors.ValidationError(message="The default resource pool cannot start remote sessions.")
×
145
    if body.remote and body.public:
2✔
146
        raise errors.ValidationError(message="A resource pool which starts remote sessions cannot be public.")
×
147
    if (body.idle_threshold and body.idle_threshold < 0) or (
2✔
148
        body.hibernation_threshold and body.hibernation_threshold < 0
149
    ):
150
        raise errors.ValidationError(message="Idle threshold and hibernation threshold need to be larger than 0.")
×
151

152
    idle_threshold = body.idle_threshold
2✔
153
    if idle_threshold == 0:
2✔
154
        idle_threshold = None
×
155
    hibernation_threshold = body.hibernation_threshold
2✔
156
    if hibernation_threshold == 0:
2✔
157
        hibernation_threshold = None
×
158
    hibernation_warning_period = validate_hibernation_warning_period(
2✔
159
        hibernation_threshold, body.hibernation_warning_period
160
    )
161
    if hibernation_warning_period is RESET:
2✔
NEW
162
        hibernation_warning_period = None
×
163

164
    quota = validate_quota(body=body.quota) if body.quota else None
2✔
165
    classes = [validate_resource_class(body=new_cls) for new_cls in body.classes]
2✔
166

167
    default_classes: list[models.UnsavedResourceClass] = []
2✔
168
    for cls in classes:
2✔
169
        if quota is not None and not quota.is_resource_class_compatible(cls):
2✔
170
            raise errors.ValidationError(
1✔
171
                message=f"The resource class with name {cls.name} is not compatible with the quota."
172
            )
173
        if cls.default:
2✔
174
            default_classes.append(cls)
1✔
175
    if len(default_classes) != 1:
2✔
176
        raise errors.ValidationError(message="One default class is required in each resource pool.")
1✔
177

178
    remote = validate_remote(body=body.remote) if body.remote else None
1✔
179
    platform = __validate_runtime_platform(body=body.platform)
1✔
180

181
    return models.UnsavedResourcePool(
1✔
182
        name=body.name,
183
        classes=classes,
184
        quota=quota,
185
        idle_threshold=idle_threshold,
186
        hibernation_threshold=hibernation_threshold,
187
        hibernation_warning_period=hibernation_warning_period,
188
        default=body.default,
189
        public=body.public,
190
        remote=remote,
191
        cluster_id=ULID.from_str(body.cluster_id) if body.cluster_id else None,
192
        platform=platform,
193
    )
194

195

196
def validate_resource_pool_put_or_patch(
2✔
197
    method: Literal["PATCH"] | Literal["PUT"], body: apispec.ResourcePoolPatch | apispec.ResourcePoolPut
198
) -> models.ResourcePoolPatch:
199
    """Validate the patch to a resource pool."""
200
    classes = (
2✔
201
        [validate_resource_class_patch_or_put(body=rc, method=method) for rc in body.classes] if body.classes else None
202
    )
203
    quota = validate_quota_put_patch(body=body.quota) if body.quota else None
2✔
204
    remote = None
2✔
205
    match body.remote:
2✔
206
        case apispec.RemoteConfigurationPatchReset() as r:
2✔
207
            remote = validate_remote_patch(body=r)
1✔
208
        case apispec.RemoteConfigurationFirecrestPatch() as r:
2✔
209
            remote = validate_remote_patch(body=r)
2✔
210
        case apispec.RemoteConfigurationFirecrest() as r:
2✔
211
            remote = validate_remote_put(r)
1✔
212

213
    platform = __validate_runtime_platform(body=body.platform) if body.platform else None
2✔
214
    hibernation_warning_period = validate_hibernation_warning_period(
2✔
215
        body.hibernation_threshold, body.hibernation_warning_period
216
    )
217
    return models.ResourcePoolPatch(
2✔
218
        name=body.name,
219
        classes=classes,
220
        quota=quota,
221
        idle_threshold=RESET if body.idle_threshold == 0 else body.idle_threshold,
222
        hibernation_threshold=RESET if body.hibernation_threshold == 0 else body.hibernation_threshold,
223
        hibernation_warning_period=hibernation_warning_period,
224
        default=body.default,
225
        public=body.public,
226
        remote=remote,
227
        cluster_id=ULID.from_str(body.cluster_id) if body.cluster_id else None,
228
        platform=platform,
229
    )
230

231

232
def validate_hibernation_warning_period(
2✔
233
    hibernation_threshold: int | ResetType | None, hibernation_warning_period: int | ResetType | None
234
) -> int | ResetType | None:
235
    """Validate hibernation_warning_period."""
236
    if hibernation_threshold is None:
2✔
237
        # cannot validate here without the existing data
238
        return hibernation_warning_period
2✔
239
    elif hibernation_warning_period is None:
2✔
240
        return None
1✔
241
    elif (
2✔
242
        hibernation_warning_period == 0
243
        or hibernation_threshold == 0
244
        or hibernation_threshold is RESET
245
        or hibernation_warning_period is RESET
246
    ):
NEW
247
        return RESET
×
248
    else:
249
        if hibernation_warning_period >= hibernation_threshold:
2✔
250
            raise errors.ValidationError(
1✔
251
                message=(
252
                    f"The hibernation_warning_period {hibernation_warning_period} must be "
253
                    f"lower than the hibernation_threshold ({hibernation_threshold})"
254
                )
255
            )
256
    return hibernation_warning_period
1✔
257

258

259
def validate_resource_pool_update(existing: models.ResourcePool, update: models.ResourcePoolPatch) -> None:
2✔
260
    """Validate the update to a resource pool."""
261
    name = update.name if update.name is not None else existing.name
1✔
262
    classes = existing.classes
1✔
263
    for rc in update.classes or []:
1✔
264
        found = next(filter(lambda tup: tup[1].id == rc.id, enumerate(classes)), None)
1✔
265
        if found is None:
1✔
266
            raise errors.ValidationError(
×
267
                message=f"Resource class '{rc.id}' does not exist in resource pool '{existing.id}'."
268
            )
269
        idx, existing_rc = found
1✔
270
        classes[idx] = models.ResourceClass(
1✔
271
            name=rc.name if rc.name is not None else existing_rc.name,
272
            cpu=rc.cpu if rc.cpu is not None else existing_rc.cpu,
273
            memory=rc.memory if rc.memory is not None else existing_rc.memory,
274
            max_storage=rc.max_storage if rc.max_storage is not None else existing_rc.max_storage,
275
            gpu=rc.gpu if rc.gpu is not None else existing_rc.gpu,
276
            id=existing_rc.id,
277
            default=rc.default if rc.default is not None else existing_rc.default,
278
            default_storage=rc.default_storage if rc.default_storage is not None else existing_rc.default_storage,
279
            node_affinities=rc.node_affinities if rc.node_affinities is not None else existing_rc.node_affinities,
280
            tolerations=rc.tolerations if rc.tolerations is not None else existing_rc.tolerations,
281
        )
282
    quota: models.Quota | models.UnsavedQuota | ResetType = existing.quota if existing.quota else RESET
1✔
283
    if update.quota is RESET:
1✔
284
        quota = RESET
×
285
    elif update.quota is not None and existing.quota is None:
1✔
286
        # The quota patch needs to contain all required fields
287
        cpu = update.quota.cpu
×
288
        if cpu is None:
×
289
            raise errors.ValidationError(message="The 'quota.cpu' field is required when creating a new quota.")
×
290
        memory = update.quota.memory
×
291
        if memory is None:
×
292
            raise errors.ValidationError(message="The 'quota.memory' field is required when creating a new quota.")
×
293
        gpu = update.quota.gpu
×
294
        if gpu is None:
×
295
            raise errors.ValidationError(message="The 'quota.gpu' field is required when creating a new quota.")
×
296
        quota = models.UnsavedQuota(
×
297
            cpu=cpu,
298
            memory=memory,
299
            gpu=gpu,
300
        )
301
    elif isinstance(update.quota, models.QuotaPatch):
1✔
302
        assert existing.quota is not None
1✔
303
        quota = models.Quota(
1✔
304
            cpu=update.quota.cpu if update.quota.cpu is not None else existing.quota.cpu,
305
            memory=update.quota.memory if update.quota.memory is not None else existing.quota.memory,
306
            gpu=update.quota.gpu if update.quota.gpu is not None else existing.quota.gpu,
307
            gpu_kind=update.quota.gpu_kind if update.quota.gpu_kind is not None else existing.quota.gpu_kind,
308
            id=existing.quota.id,
309
        )
310
    idle_threshold = update.idle_threshold if update.idle_threshold is not None else existing.idle_threshold
1✔
311
    hibernation_threshold = (
1✔
312
        update.hibernation_threshold if update.hibernation_threshold is not None else existing.hibernation_threshold
313
    )
314
    hibernation_warining_period = validate_hibernation_warning_period(
1✔
315
        hibernation_threshold, update.hibernation_warning_period
316
    )
317
    if hibernation_warining_period is None:
1✔
318
        hibernation_warining_period = existing.hibernation_warning_period
1✔
319
    elif hibernation_warining_period is RESET:
1✔
NEW
320
        hibernation_warining_period = None
×
321

322
    default = update.default if update.default is not None else existing.default
1✔
323
    public = update.public if update.public is not None else existing.public
1✔
324
    remote: models.RemoteConfigurationFirecrest | ResetType = existing.remote if existing.remote else RESET
1✔
325
    if update.remote is RESET:
1✔
326
        remote = RESET
1✔
327
    elif update.remote is not None and existing.remote is None:
1✔
328
        # The remote patch needs to contain all required fields
329
        kind = update.remote.kind
1✔
330
        if kind is None:
1✔
331
            raise errors.ValidationError(message="The 'remote.kind' field is required when creating a new remote.")
×
332
        api_url = update.remote.api_url
1✔
333
        if api_url is None:
1✔
334
            raise errors.ValidationError(message="The 'remote.api_url' field is required when creating a new remote.")
×
335
        system_name = update.remote.system_name
1✔
336
        if system_name is None:
1✔
337
            raise errors.ValidationError(
×
338
                message="The 'remote.system_name' field is required when creating a new remote."
339
            )
340
        remote = models.RemoteConfigurationFirecrest(
1✔
341
            kind=kind,
342
            provider_id=update.remote.provider_id,
343
            api_url=api_url,
344
            system_name=system_name,
345
            partition=update.remote.partition,
346
        )
347
    elif isinstance(update.remote, models.RemoteConfigurationFirecrestPatch):
1✔
348
        assert existing.remote is not None
×
349
        remote = models.RemoteConfigurationFirecrest(
×
350
            kind=update.remote.kind if update.remote.kind is not None else existing.remote.kind,
351
            provider_id=update.remote.provider_id
352
            if update.remote.provider_id is not None
353
            else existing.remote.provider_id,
354
            api_url=update.remote.api_url if update.remote.api_url is not None else existing.remote.api_url,
355
            system_name=update.remote.system_name
356
            if update.remote.system_name is not None
357
            else existing.remote.system_name,
358
            partition=update.remote.partition if update.remote.partition is not None else existing.remote.partition,
359
        )
360

361
    if len(name) > 40:
1✔
362
        # TODO: Should this be added to the API spec instead?
363
        raise errors.ValidationError(message="'name' cannot be longer than 40 characters.")
×
364
    if default and not public:
1✔
365
        raise errors.ValidationError(message="The default resource pool has to be public.")
×
366
    if default and quota is not RESET:
1✔
367
        raise errors.ValidationError(message="A default resource pool cannot have a quota.")
×
368
    if isinstance(remote, models.RemoteConfigurationFirecrest) and default:
1✔
369
        raise errors.ValidationError(message="The default resource pool cannot start remote sessions.")
×
370
    if isinstance(remote, models.RemoteConfigurationFirecrest) and public:
1✔
371
        raise errors.ValidationError(message="A resource pool which starts remote sessions cannot be public.")
×
372
    if (isinstance(idle_threshold, int) and idle_threshold < 0) or (
1✔
373
        isinstance(hibernation_threshold, int) and hibernation_threshold < 0
374
    ):
375
        raise errors.ValidationError(message="Idle threshold and hibernation threshold need to be larger than 0.")
×
376

377
    default_classes: list[models.ResourceClass] = []
1✔
378
    for cls in classes:
1✔
379
        if (isinstance(quota, (models.Quota, models.UnsavedQuota))) and not quota.is_resource_class_compatible(cls):
1✔
380
            raise errors.ValidationError(
×
381
                message=f"The resource class with name {cls.name} is not compatible with the quota."
382
            )
383
        if cls.default:
1✔
384
            default_classes.append(cls)
1✔
385
    if len(default_classes) != 1:
1✔
386
        raise errors.ValidationError(message="One default class is required in each resource pool.")
×
387

388

389
def validate_cluster(body: apispec.Cluster) -> models.ClusterSettings:
2✔
390
    """Convert a REST API Cluster object to a model Cluster object."""
391
    return models.ClusterSettings(
2✔
392
        name=body.name,
393
        config_name=body.config_name,
394
        session_protocol=models.SessionProtocol(body.session_protocol.value),
395
        session_host=body.session_host,
396
        session_port=body.session_port,
397
        session_path=body.session_path,
398
        session_ingress_class_name=body.session_ingress_class_name,
399
        session_ingress_annotations=body.session_ingress_annotations.model_dump(),
400
        session_tls_secret_name=body.session_tls_secret_name,
401
        session_storage_class=body.session_storage_class,
402
        service_account_name=body.service_account_name,
403
    )
404

405

406
def validate_cluster_patch(patch: apispec.ClusterPatch) -> models.ClusterPatch:
2✔
407
    """Convert a REST API Cluster object patch to a model Cluster object."""
408

409
    return models.ClusterPatch(
2✔
410
        name=patch.name,
411
        config_name=patch.config_name,
412
        session_protocol=models.SessionProtocol(patch.session_protocol.value)
413
        if patch.session_protocol is not None
414
        else None,
415
        session_host=patch.session_host,
416
        session_port=patch.session_port,
417
        session_path=patch.session_path,
418
        session_ingress_class_name=patch.session_ingress_class_name,
419
        session_ingress_annotations=patch.session_ingress_annotations.model_dump()
420
        if patch.session_ingress_annotations is not None
421
        else None,
422
        session_tls_secret_name=patch.session_tls_secret_name,
423
        session_storage_class=patch.session_storage_class,
424
        service_account_name=patch.service_account_name,
425
    )
426

427

428
def validate_remote(body: apispec.RemoteConfigurationFirecrest) -> models.RemoteConfigurationFirecrest:
2✔
429
    """Validate a remote configuration object."""
430
    kind = models.RemoteConfigurationKind(body.kind.value)
2✔
431
    if kind != models.RemoteConfigurationKind.firecrest:
2✔
432
        raise errors.ValidationError(message=f"The kind '{kind}' of remote configuration is not supported.", quiet=True)
×
433
    validate_firecrest_api_url(body.api_url)
2✔
434
    return models.RemoteConfigurationFirecrest(
2✔
435
        kind=kind,
436
        provider_id=body.provider_id,
437
        api_url=body.api_url,
438
        system_name=body.system_name,
439
        partition=body.partition,
440
    )
441

442

443
def validate_remote_put(
2✔
444
    body: apispec.RemoteConfigurationFirecrest | None,
445
) -> models.RemoteConfigurationPatch:
446
    """Validate the PUT update to a remote configuration object."""
447
    if body is None:
1✔
448
        return RESET
×
449
    remote = validate_remote(body=body)
1✔
450
    return models.RemoteConfigurationFirecrestPatch(
1✔
451
        kind=remote.kind,
452
        provider_id=remote.provider_id,
453
        api_url=remote.api_url,
454
        system_name=remote.system_name,
455
        partition=remote.partition,
456
    )
457

458

459
def validate_remote_patch(
2✔
460
    body: apispec.RemoteConfigurationPatchReset | apispec.RemoteConfigurationFirecrestPatch,
461
) -> models.RemoteConfigurationPatch:
462
    """Validate the patch to a remote configuration object."""
463
    if isinstance(body, apispec.RemoteConfigurationPatchReset):
2✔
464
        return RESET
1✔
465
    kind = models.RemoteConfigurationKind(body.kind.value) if body.kind else None
2✔
466
    if kind and kind != models.RemoteConfigurationKind.firecrest:
2✔
467
        raise errors.ValidationError(message=f"The kind '{kind}' of remote configuration is not supported.", quiet=True)
×
468
    if body.api_url:
2✔
469
        validate_firecrest_api_url(body.api_url)
2✔
470
    return models.RemoteConfigurationFirecrestPatch(
2✔
471
        kind=kind,
472
        provider_id=body.provider_id,
473
        api_url=body.api_url,
474
        system_name=body.system_name,
475
        partition=body.partition,
476
    )
477

478

479
def validate_firecrest_api_url(url: str) -> None:
2✔
480
    """Validate the URL to the FirecREST API."""
481
    parsed = urlparse(url)
2✔
482
    if not parsed.netloc:
2✔
483
        raise errors.ValidationError(
1✔
484
            message=f"The host for the firecrest api url {url} is not valid, expected a non-empty value.",
485
            quiet=True,
486
        )
487
    accepted_schemes = ["https"]
2✔
488
    if parsed.scheme not in accepted_schemes:
2✔
489
        raise errors.ValidationError(
×
490
            message=f"The scheme for the firecrest api url {url} is not valid, expected one of {accepted_schemes}",
491
            quiet=True,
492
        )
493

494

495
def __validate_runtime_platform(body: apispec.RuntimePlatform | None) -> models.RuntimePlatform:
2✔
496
    """Validate the platform field for resource pools."""
497
    platform_str: str = models.RuntimePlatform.linux_amd64
2✔
498
    if body:
2✔
499
        platform_str = body.value
2✔
500
    if platform_str not in models.RuntimePlatform:
2✔
501
        raise errors.ValidationError(
×
502
            message=(
503
                f"Invalid value for the field 'platform': {body}: "
504
                f"Valid values are {[e.value for e in models.RuntimePlatform]}"
505
            )
506
        )
507
    return models.RuntimePlatform(platform_str)
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc