• 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

87.1
/components/renku_data_services/notebooks/crs.py
1
"""Custom resource definition with proper names from the autogenerated code."""
2

3
from __future__ import annotations
2✔
4

5
import re
2✔
6
from collections.abc import Mapping, Sequence
2✔
7
from datetime import datetime, timedelta
2✔
8
from typing import Any, cast, override
2✔
9
from urllib.parse import urlunparse
2✔
10

11
from kubernetes.utils import parse_duration, parse_quantity
2✔
12
from kubernetes.utils.duration import format_duration
2✔
13
from pydantic import BaseModel, Field, field_serializer, field_validator, model_serializer
2✔
14
from pydantic.types import HashableItemType
2✔
15
from ulid import ULID
2✔
16

17
from renku_data_services.base_models.core import RESET, ResetType
2✔
18
from renku_data_services.errors import errors
2✔
19
from renku_data_services.notebooks import apispec
2✔
20
from renku_data_services.notebooks.constants import AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK
2✔
21
from renku_data_services.notebooks.cr_amalthea_session import Affinity as _Affinity
2✔
22
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
23
    Authentication,
24
    CodeRepository,
25
    DataSource,
26
    EmptyDir,
27
    ExtraContainer,
28
    ExtraVolume,
29
    ExtraVolumeMount,
30
    ImagePullPolicy,
31
    ImagePullSecret,
32
    Ingress,
33
    InitContainer,
34
    MatchExpression,
35
    NodeAffinity,
36
    NodeSelectorTerm,
37
    PodAffinity,
38
    PodAntiAffinity,
39
    Preference,
40
    PreferredDuringSchedulingIgnoredDuringExecutionItem,
41
    ReconcileStrategy,
42
    RemoteSecretRef,
43
    RequiredDuringSchedulingIgnoredDuringExecution,
44
    RequiredDuringSchedulingIgnoredDuringExecutionItem,
45
    Size,
46
    State,
47
    Status,
48
    Storage,
49
    TlsSecret,
50
    Toleration,
51
)
52
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
53
    Culling as _ASCulling,
54
)
55
from renku_data_services.notebooks.cr_amalthea_session import EnvItem2 as SessionEnvItem
2✔
56
from renku_data_services.notebooks.cr_amalthea_session import Item4 as SecretAsVolumeItem
2✔
57
from renku_data_services.notebooks.cr_amalthea_session import Limits6 as _Limits
2✔
58
from renku_data_services.notebooks.cr_amalthea_session import Limits7 as LimitsStr
2✔
59
from renku_data_services.notebooks.cr_amalthea_session import Location as SessionLocation
2✔
60
from renku_data_services.notebooks.cr_amalthea_session import Model as _ASModel
2✔
61
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
62
    PreferredDuringSchedulingIgnoredDuringExecutionItem1 as PreferredPodAffinityItem,
63
)
64
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
65
    PreferredDuringSchedulingIgnoredDuringExecutionItem2 as PreferredPodAntiAffinityItem,
66
)
67
from renku_data_services.notebooks.cr_amalthea_session import Requests6 as _Requests
2✔
68
from renku_data_services.notebooks.cr_amalthea_session import Requests7 as RequestsStr
2✔
69
from renku_data_services.notebooks.cr_amalthea_session import (
2✔
70
    RequiredDuringSchedulingIgnoredDuringExecutionItem1 as RequiredPodAntiAffinityItem,
71
)
72
from renku_data_services.notebooks.cr_amalthea_session import Resources3 as _Resources
2✔
73
from renku_data_services.notebooks.cr_amalthea_session import Secret1 as SecretAsVolume
2✔
74
from renku_data_services.notebooks.cr_amalthea_session import SecretRef as _SecretRef
2✔
75
from renku_data_services.notebooks.cr_amalthea_session import Session as _ASSession
2✔
76
from renku_data_services.notebooks.cr_amalthea_session import ShmSize1 as ShmSizeStr
2✔
77
from renku_data_services.notebooks.cr_amalthea_session import Size1 as SizeStr
2✔
78
from renku_data_services.notebooks.cr_amalthea_session import Spec as _ASSpec
2✔
79
from renku_data_services.notebooks.cr_amalthea_session import Type as AuthenticationType
2✔
80
from renku_data_services.notebooks.cr_amalthea_session import Type1 as CodeRepositoryType
2✔
81
from renku_data_services.notebooks.cr_base import BaseCRD
2✔
82
from renku_data_services.notebooks.cr_jupyter_server import Model as _JSModel
2✔
83
from renku_data_services.notebooks.cr_jupyter_server import Patch
2✔
84
from renku_data_services.notebooks.cr_jupyter_server import Spec as JupyterServerSpec
2✔
85
from renku_data_services.notebooks.cr_jupyter_server import Type as PatchType
2✔
86

87

88
class Metadata(BaseModel):
2✔
89
    """Basic k8s metadata spec."""
90

91
    class Config:
2✔
92
        """Do not exclude unknown properties."""
93

94
        extra = "allow"
2✔
95

96
    name: str
2✔
97
    namespace: str | None = None
2✔
98
    labels: dict[str, str] = Field(default_factory=dict)
2✔
99
    annotations: dict[str, str] = Field(default_factory=dict)
2✔
100
    uid: str | None = None
2✔
101
    creationTimestamp: datetime | None = None
2✔
102
    deletionTimestamp: datetime | None = None
2✔
103

104

105
class ComputeResources(BaseModel):
2✔
106
    """Resource requests from k8s values."""
107

108
    cpu: float | None = None
2✔
109
    memory: int | None = None
2✔
110
    storage: int | None = None
2✔
111
    gpu: int | None = None
2✔
112

113
    @field_validator("cpu", mode="before")
2✔
114
    @classmethod
2✔
115
    def _convert_k8s_cpu(cls, val: Any) -> Any:
2✔
116
        if val is None:
1✔
117
            return None
×
118
        if hasattr(val, "root"):
1✔
119
            val = val.root
×
120
        return float(parse_quantity(val))
1✔
121

122
    @field_validator("gpu", mode="before")
2✔
123
    @classmethod
2✔
124
    def _convert_k8s_gpu(cls, val: Any) -> Any:
2✔
125
        if val is None:
×
126
            return None
×
127
        if hasattr(val, "root"):
×
128
            val = val.root
×
129
        return round(parse_quantity(val), ndigits=None)
×
130

131
    @field_validator("memory", "storage", mode="before")
2✔
132
    @classmethod
2✔
133
    def _convert_k8s_bytes(cls, val: Any) -> Any:
2✔
134
        """Converts to gigabytes of base 10."""
135
        if val is None:
1✔
136
            return None
×
137
        if hasattr(val, "root"):
1✔
138
            val = val.root
×
139
        return round(parse_quantity(val) / 1_000_000_000, ndigits=None)
1✔
140

141

142
class JupyterServerV1Alpha1(_JSModel):
2✔
143
    """Jupyter server CRD."""
144

145
    kind: str = JUPYTER_SESSION_GVK.kind
2✔
146
    apiVersion: str = JUPYTER_SESSION_GVK.group_version
2✔
147
    metadata: Metadata
2✔
148

149
    def get_compute_resources(self) -> ComputeResources:
2✔
150
        """Convert the k8s resource requests and storage into usable values."""
151
        if self.spec is None:
×
152
            return ComputeResources()
×
153
        resource_requests: dict = self.spec.jupyterServer.resources.get("requests", {})
×
154
        resource_requests["storage"] = self.spec.storage.size
×
155
        return ComputeResources.model_validate(resource_requests)
×
156

157
    def resource_class_id(self) -> int:
2✔
158
        """Get the resource class from the annotations."""
159
        if "renku.io/resourceClassId" not in self.metadata.annotations:
×
160
            raise errors.ProgrammingError(
×
161
                message=f"The session with name {self.metadata.name} is missing its renku.io/resourceClassId annotation"
162
            )
163
        i = int(self.metadata.annotations["renku.io/resourceClassId"])
×
164
        return i
×
165

166

167
class CullingDurationParsingMixin(BaseCRD):
2✔
168
    """Amalthea session culling configuration."""
169

170
    @field_serializer("*", mode="wrap")
2✔
171
    def __serialize_duration_field(self, val: Any, nxt: Any, _info: Any) -> Any:
2✔
172
        if isinstance(val, timedelta):
1✔
173
            return format_duration(val)
1✔
174
        if val is RESET:
1✔
175
            return None
1✔
176
        return nxt(val)
1✔
177

178
    @field_validator(
2✔
179
        "maxAge",
180
        "maxFailedDuration",
181
        "maxHibernatedDuration",
182
        "maxIdleDuration",
183
        "maxStartingDuration",
184
        mode="wrap",
185
        check_fields=False,
186
    )
187
    @classmethod
2✔
188
    def __deserialize_duration(cls, val: Any, handler: Any) -> Any:
2✔
189
        if isinstance(val, str):
1✔
190
            try:
1✔
191
                return safe_parse_duration(val)
1✔
NEW
192
            except Exception:
×
NEW
193
                return handler(val)
×
194
        return handler(val)
1✔
195

196

197
class Culling(_ASCulling, CullingDurationParsingMixin):
2✔
198
    """Amalthea session culling configuration.
199

200
    A value of zero for any of the values indicates that the automatic action will never happen.
201
    I.e. a value of zero for `maxIdleDuration` indicates that the session will never be hibernated
202
    no matter how long it is idle.
203
    """
204

205
    pass
2✔
206

207

208
class Requests(_Requests):
2✔
209
    """Resource requests of type integer."""
210

211
    root: int
2✔
212

213

214
class Limits(_Limits):
2✔
215
    """Resource limits of type integer."""
216

217
    root: int
2✔
218

219

220
class Resources(_Resources):
2✔
221
    """Resource requests and limits spec.
222

223
    Overriding these is necessary because of
224
    https://docs.pydantic.dev/2.11/errors/validation_errors/#string_type.
225
    An integer model cannot have a regex pattern for validation in pydantic.
226
    But the code generation applies the pattern constraint to both the int and string variations
227
    of the fields. But the int variation runs and blows up at runtime only when an int is passed
228
    for validation.
229
    """
230

231
    limits: Mapping[str, LimitsStr | Limits] | None = None
2✔
232
    requests: Mapping[str, RequestsStr | Requests] | None = None
2✔
233

234

235
class SecretRef(_SecretRef, RemoteSecretRef):
2✔
236
    """Reference to a secret."""
237

238
    pass
2✔
239

240

241
class Session(_ASSession):
2✔
242
    """Amalthea spec.session schema."""
243

244
    resources: Resources | None = None
2✔
245
    remoteSecretRef: SecretRef | None = None
2✔
246

247

248
class AmaltheaSessionSpec(_ASSpec):
2✔
249
    """Amalthea session specification."""
250

251
    session: Session
2✔
252
    culling: Culling | None = None
2✔
253

254

255
class AmaltheaSessionV1Alpha1(_ASModel):
2✔
256
    """Amalthea session CRD."""
257

258
    kind: str = AMALTHEA_SESSION_GVK.kind
2✔
259
    apiVersion: str = AMALTHEA_SESSION_GVK.group_version
2✔
260
    # Here we overwrite the default from ASModel because it is too weakly typed
261
    metadata: Metadata  # type: ignore[assignment]
2✔
262
    spec: AmaltheaSessionSpec
2✔
263

264
    def get_compute_resources(self) -> ComputeResources:
2✔
265
        """Convert the k8s resource requests and storage into usable values."""
266
        resource_requests: dict = {}
1✔
267
        if self.spec.session.resources is not None:
1✔
268
            reqs = self.spec.session.resources.requests or {}
1✔
269
            reqs = {k: parse_quantity(v.root if hasattr(v, "root") else v) for k, v in reqs.items()}
1✔
270
            resource_requests = {
1✔
271
                **reqs,
272
                "storage": parse_quantity(self.spec.session.storage.size.root),
273
            }
274
        return ComputeResources.model_validate(resource_requests)
1✔
275

276
    @property
2✔
277
    def project_id(self) -> ULID:
2✔
278
        """Get the project ID from the annotations."""
279
        if "renku.io/project_id" not in self.metadata.annotations:
1✔
280
            raise errors.ProgrammingError(
×
281
                message=f"The session with name {self.metadata.name} is missing its project_id annotation"
282
            )
283
        return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/project_id"]))
1✔
284

285
    @property
2✔
286
    def launcher_id(self) -> ULID:
2✔
287
        """Get the launcher ID from the annotations."""
288
        if "renku.io/launcher_id" not in self.metadata.annotations:
1✔
289
            raise errors.ProgrammingError(
×
290
                message=f"The session with name {self.metadata.name} is missing its launcher_id annotation"
291
            )
292
        return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/launcher_id"]))
1✔
293

294
    def resource_class_id(self) -> int:
2✔
295
        """Get the resource class from the annotations."""
296
        if "renku.io/resource_class_id" not in self.metadata.annotations:
1✔
297
            raise errors.ProgrammingError(
×
298
                message=f"The session with name {self.metadata.name} is missing its resource_class_id annotation"
299
            )
300
        return int(self.metadata.annotations["renku.io/resource_class_id"])
1✔
301

302
    def as_apispec(self) -> apispec.SessionResponse:
2✔
303
        """Convert the manifest into a form ready to be serialized and sent in a HTTP response."""
304
        if self.status is None:
1✔
305
            raise errors.ProgrammingError(
×
306
                message=f"The manifest for a session with name {self.metadata.name} cannot be serialized "
307
                f"because it is missing a status"
308
            )
309
        if self.spec is None:
1✔
310
            raise errors.ProgrammingError(
×
311
                message=f"The manifest for a session with name {self.metadata.name} cannot be serialized "
312
                "because it is missing the spec field"
313
            )
314
        if self.spec.session.resources is None:
1✔
315
            raise errors.ProgrammingError(
×
316
                message=f"The manifest for a session with name {self.metadata.name} cannot be serialized "
317
                "because it is missing the spec.session.resources field"
318
            )
319
        url = "None"
1✔
320
        if self.base_url is not None:
1✔
321
            url = self.base_url
1✔
322
        ready_containers = 0
1✔
323
        total_containers = 0
1✔
324
        if self.status.initContainerCounts is not None:
1✔
325
            ready_containers += self.status.initContainerCounts.ready or 0
1✔
326
            total_containers += self.status.initContainerCounts.total or 0
1✔
327
        if self.status.containerCounts is not None:
1✔
328
            ready_containers += self.status.containerCounts.ready or 0
1✔
329
            total_containers += self.status.containerCounts.total or 0
1✔
330

331
        if self.status.state in [State.Running, State.Hibernated, State.Failed]:
1✔
332
            # Amalthea is sometimes slow when (un)hibernating and still shows the old status, so we patch it here
333
            # so the client sees the correct state
334
            if not self.spec.hibernated and self.status.state == State.Hibernated:
1✔
335
                state = apispec.State3.starting
×
336
            elif self.spec.hibernated and self.status.state == State.Running:
1✔
337
                state = apispec.State3.hibernated
×
338
            else:
339
                state = apispec.State3(self.status.state.value.lower())
1✔
340
        elif self.status.state == State.RunningDegraded:
1✔
341
            state = apispec.State3.running
×
342
        elif self.status.state == State.NotReady and self.metadata.deletionTimestamp is not None:
1✔
343
            state = apispec.State3.stopping
×
344
        else:
345
            state = apispec.State3.starting
1✔
346

347
        will_delete_at: datetime | None = None
1✔
348
        match self.status, self.spec.culling:
1✔
349
            case (
1✔
350
                Status(state=State.Hibernated, hibernatedSince=hibernated_since),
351
                Culling(maxHibernatedDuration=max_hibernated),
352
            ) if hibernated_since and max_hibernated:
353
                will_delete_at = hibernated_since + max_hibernated
×
354

355
        return apispec.SessionResponse(
1✔
356
            image=self.spec.session.image,
357
            name=self.metadata.name,
358
            resources=apispec.SessionResources(
359
                requests=apispec.SessionResourcesRequests.model_validate(
360
                    self.get_compute_resources(), from_attributes=True
361
                )
362
                if self.spec.session.resources.requests is not None
363
                else None,
364
            ),
365
            started=self.metadata.creationTimestamp,
366
            lastInteraction=self.spec.culling.lastInteraction if self.spec.culling else None,
367
            status=apispec.SessionStatus(
368
                state=state,
369
                ready_containers=ready_containers,
370
                total_containers=total_containers,
371
                will_hibernate_at=self.status.willHibernateAt,
372
                will_delete_at=will_delete_at,
373
                message=self.status.error,
374
            ),
375
            url=url,
376
            project_id=str(self.project_id),
377
            launcher_id=str(self.launcher_id),
378
            resource_class_id=self.resource_class_id(),
379
        )
380

381
    @property
2✔
382
    def base_url(self) -> str | None:
2✔
383
        """Get the URL of the session, excluding the default URL from the session launcher."""
384
        if self.status.url and len(self.status.url) > 0:
1✔
385
            return self.status.url
1✔
386
        if self.spec is None or self.spec.ingress is None:
1✔
387
            return None
×
388
        scheme = "https" if self.spec and self.spec.ingress and self.spec.ingress.tlsSecret else "http"
1✔
389
        host = self.spec.ingress.host
1✔
390
        path = self.spec.session.urlPath if self.spec.session.urlPath else "/"
1✔
391
        if not path.endswith("/"):
1✔
392
            path += "/"
1✔
393
        params = None
1✔
394
        query = None
1✔
395
        fragment = None
1✔
396
        url = urlunparse((scheme, host, path, params, query, fragment))
1✔
397
        return url
1✔
398

399

400
class ResourcesPatch(BaseCRD):
2✔
401
    """Resource requests and limits patch."""
402

403
    limits: Mapping[str, LimitsStr | Limits | ResetType] | ResetType | None = None
2✔
404
    requests: Mapping[str, RequestsStr | Requests | ResetType] | ResetType | None = None
2✔
405

406

407
class AmaltheaSessionV1Alpha1SpecSessionPatch(BaseCRD):
2✔
408
    """Patch for the main session config."""
409

410
    resources: ResourcesPatch | ResetType | None = None
2✔
411
    shmSize: int | str | ResetType | None = None
2✔
412
    storage: Storage | ResetType | None = None
2✔
413
    imagePullPolicy: ImagePullPolicy | None = None
2✔
414
    extraVolumeMounts: list[ExtraVolumeMount] | ResetType | None = None
2✔
415

416

417
class AmaltheaSessionV1Alpha1MetadataPatch(BaseCRD):
2✔
418
    """Patch for the metadata of an amalthea session."""
419

420
    annotations: dict[str, str | ResetType] | ResetType | None = None
2✔
421

422

423
class NodeAffinityPatch(BaseCRD):
2✔
424
    """Patch for the node affinity of a session."""
425

426
    preferredDuringSchedulingIgnoredDuringExecution: (
2✔
427
        Sequence[PreferredDuringSchedulingIgnoredDuringExecutionItem] | None | ResetType
428
    ) = None
429
    requiredDuringSchedulingIgnoredDuringExecution: (
2✔
430
        RequiredDuringSchedulingIgnoredDuringExecution | None | ResetType
431
    ) = None
432

433

434
class PodAffinityPatch(BaseCRD):
2✔
435
    """Patch for the pod affinity of a session."""
436

437
    preferredDuringSchedulingIgnoredDuringExecution: Sequence[PreferredPodAffinityItem] | None | ResetType = None
2✔
438
    requiredDuringSchedulingIgnoredDuringExecution: (
2✔
439
        Sequence[RequiredDuringSchedulingIgnoredDuringExecutionItem] | None | ResetType
440
    ) = None
441

442

443
class PodAntiAffinityPatch(BaseCRD):
2✔
444
    """Patch for the pod anti affinity of a session."""
445

446
    preferredDuringSchedulingIgnoredDuringExecution: Sequence[PreferredPodAntiAffinityItem] | None | ResetType = None
2✔
447
    requiredDuringSchedulingIgnoredDuringExecution: Sequence[RequiredPodAntiAffinityItem] | None | ResetType = None
2✔
448

449

450
class AffinityPatch(BaseCRD):
2✔
451
    """Patch for the affinity of a session."""
452

453
    nodeAffinity: NodeAffinityPatch | ResetType | None = None
2✔
454
    podAffinity: PodAffinityPatch | ResetType | None = None
2✔
455
    podAntiAffinity: PodAntiAffinityPatch | ResetType | None = None
2✔
456

457

458
class CullingPatch(CullingDurationParsingMixin):
2✔
459
    """Patch for the culling durations of a session."""
460

461
    maxAge: timedelta | ResetType | None = None
2✔
462
    maxFailedDuration: timedelta | ResetType | None = None
2✔
463
    maxHibernatedDuration: timedelta | ResetType | None = None
2✔
464
    maxIdleDuration: timedelta | ResetType | None = None
2✔
465
    maxStartingDuration: timedelta | ResetType | None = None
2✔
466
    lastInteraction: datetime | ResetType | None = None
2✔
467

468

469
class AmaltheaSessionV1Alpha1SpecPatch(BaseCRD):
2✔
470
    """Patch for the spec of an amalthea session."""
471

472
    extraContainers: list[ExtraContainer] | ResetType | None = None
2✔
473
    extraVolumes: list[ExtraVolume] | ResetType | None = None
2✔
474
    hibernated: bool | None = None
2✔
475
    initContainers: list[InitContainer] | ResetType | None = None
2✔
476
    imagePullSecrets: list[ImagePullSecret] | ResetType | None = None
2✔
477
    priorityClassName: str | ResetType | None = None
2✔
478
    tolerations: list[Toleration] | ResetType | None = None
2✔
479
    affinity: AffinityPatch | ResetType | None = None
2✔
480
    session: AmaltheaSessionV1Alpha1SpecSessionPatch | None = None
2✔
481
    culling: CullingPatch | ResetType | None = None
2✔
482
    service_account_name: str | ResetType | None = None
2✔
483

484

485
class AmaltheaSessionV1Alpha1Patch(BaseCRD):
2✔
486
    """Patch for an amalthea session."""
487

488
    metadata: AmaltheaSessionV1Alpha1MetadataPatch | None = None
2✔
489
    spec: AmaltheaSessionV1Alpha1SpecPatch
2✔
490

491
    def to_rfc7386(self) -> dict[str, Any]:
2✔
492
        """Generate the patch to be applied to the session.
493

494
        Note that when the value for a key in the patch is anything other than a
495
        dictionary then the rfc7386 patch will replace, not merge.
496
        """
497
        return self.model_dump(exclude_none=True, mode="json")
1✔
498

499

500
def safe_parse_duration(val: Any) -> timedelta:
2✔
501
    """Required because parse_duration from k8s can only deal with values with up to 5 digits.
502

503
    Values with 1 unit only (like seconds) and high values will cause things to fail.
504
    For example `parse_duration("1210500s")` will raise ValueError whereas `parse_duration("100s")` will be fine.
505
    This does not make the whole thing 100% foolproof but it eliminates errors like the above which
506
    we have seen in production.
507
    """
508
    if isinstance(val, timedelta):
1✔
509
        return val
×
510
    m = re.match(r"^([0-9]+)(h|m|s|ms)$", str(val))
1✔
511
    if m is not None:
1✔
512
        num = m.group(1)
1✔
513
        unit = m.group(2)
1✔
514
        match unit:
1✔
515
            case "h":
1✔
516
                return timedelta(hours=float(num))
1✔
517
            case "m":
1✔
518
                return timedelta(minutes=float(num))
×
519
            case "s":
1✔
520
                return timedelta(seconds=float(num))
1✔
521
            case "ms":
×
522
                return timedelta(microseconds=float(num))
×
523
    return cast(timedelta, parse_duration(val))
1✔
524

525

526
class Affinity(_Affinity):
2✔
527
    """Model for the affinity of an Amalthea session."""
528

529
    @property
2✔
530
    def is_empty(self) -> bool:
2✔
531
        """Indicates whether there is anything set at all in any of the affinity fields, or they are just all empty."""
532
        node_affinity_is_empty = not (
1✔
533
            self.nodeAffinity
534
            and (
535
                self.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution
536
                or self.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution
537
            )
538
        )
539
        pod_affinity_is_empty = not (
1✔
540
            self.podAffinity
541
            and (
542
                self.podAffinity.preferredDuringSchedulingIgnoredDuringExecution
543
                or self.podAffinity.requiredDuringSchedulingIgnoredDuringExecution
544
            )
545
        )
546
        pod_antiaffinity_is_empty = not (
1✔
547
            self.podAntiAffinity
548
            and (
549
                self.podAntiAffinity.preferredDuringSchedulingIgnoredDuringExecution
550
                or self.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution
551
            )
552
        )
553
        return node_affinity_is_empty and pod_affinity_is_empty and pod_antiaffinity_is_empty
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

© 2025 Coveralls, Inc