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

karellen / kubernator / 24612814735

18 Apr 2026 08:04PM UTC coverage: 78.012% (+0.07%) from 77.939%
24612814735

Pull #103

github

web-flow
Merge bf56d31ca into ab4ca7254
Pull Request #103: Add project plugin v1: scope, ownership, cleanup

737 of 1142 branches covered (64.54%)

Branch coverage included in aggregate %.

446 of 560 new or added lines in 5 files covered. (79.64%)

7 existing lines in 2 files now uncovered.

3478 of 4261 relevant lines covered (81.62%)

4.06 hits per line

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

81.37
/src/main/python/kubernator/plugins/k8s_api.py
1
# -*- coding: utf-8 -*-
2
#
3
#   Copyright 2020 Express Systems USA, Inc
4
#   Copyright 2021 Karellen, Inc.
5
#
6
#   Licensed under the Apache License, Version 2.0 (the "License");
7
#   you may not use this file except in compliance with the License.
8
#   You may obtain a copy of the License at
9
#
10
#       http://www.apache.org/licenses/LICENSE-2.0
11
#
12
#   Unless required by applicable law or agreed to in writing, software
13
#   distributed under the License is distributed on an "AS IS" BASIS,
14
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
#   See the License for the specific language governing permissions and
16
#   limitations under the License.
17
#
18

19
import base64
5✔
20
import json
5✔
21
import re
5✔
22
from collections import namedtuple
5✔
23
from collections.abc import Callable, Mapping, MutableMapping, Sequence, Iterable
5✔
24
from enum import Enum, auto
5✔
25
from functools import partial, wraps
5✔
26
from pathlib import Path
5✔
27
from typing import Union, Optional
5✔
28

29
import yaml
5✔
30
from jsonschema._format import FormatChecker
5✔
31
from jsonschema._keywords import required
5✔
32
from jsonschema.exceptions import ValidationError
5✔
33
from jsonschema.validators import extend, Draft7Validator
5✔
34
from openapi_schema_validator import OAS31Validator
5✔
35

36
from kubernator.api import load_file, FileType, load_remote_file, calling_frame_source, parse_yaml_docs
5✔
37

38

39
def api_exc_normalize_body(e):
5✔
40
    """Parse a raw ApiException body (JSON or YAML) into a Python object in-place.
41

42
    Idempotent: if e.body is already a parsed object, it is left unchanged.
43
    """
44
    if not isinstance(e.body, (str, bytes)):
5!
45
        return
×
46
    if e.headers and "content-type" in e.headers:
5!
47
        content_type = e.headers["content-type"]
5✔
48
        if content_type == "application/json" or content_type.endswith("+json"):
5!
49
            e.body = json.loads(e.body)
5✔
50
        elif (content_type in ("application/yaml", "application/x-yaml", "text/yaml",
×
51
                               "text/x-yaml") or content_type.endswith("+yaml")):
52
            e.body = yaml.safe_load(e.body)
×
53

54

55
def api_exc_format_body(e):
5✔
56
    """Format an ApiException body back to a string for human-readable display.
57

58
    Idempotent: if e.body is already a string/bytes, it is left unchanged.
59
    """
60
    if not isinstance(e.body, (str, bytes)):
5!
61
        e.body = json.dumps(e.body, indent=4)
5✔
62

63

64
def _normalize_api_exc(func):
5✔
65
    """Decorator: normalize ApiException body before propagating."""
66
    @wraps(func)
5✔
67
    def wrapper(*args, **kwargs):
5✔
68
        from kubernetes.client import ApiException
5✔
69
        try:
5✔
70
            return func(*args, **kwargs)
5✔
71
        except ApiException as e:
5✔
72
            api_exc_normalize_body(e)
5✔
73
            raise
5✔
74
    return wrapper
5✔
75

76

77
K8S_WARNING_HEADER = re.compile(r'(?:,\s*)?(\d{3})\s+(\S+)\s+"(.+?)(?<!\\)"(?:\s+\"(.+?)(?<!\\)\")?\s*')
5✔
78
UPPER_FOLLOWED_BY_LOWER_RE = re.compile(r"(.)([A-Z][a-z]+)")
5✔
79
LOWER_OR_NUM_FOLLOWED_BY_UPPER_RE = re.compile(r"([a-z0-9])([A-Z])")
5✔
80

81
K8S_MINIMAL_RESOURCE_SCHEMA = {
5✔
82
    "properties": {
83
        "apiVersion": {
84
            "type": "string"
85
        },
86
        "kind": {
87
            "type": "string"
88
        },
89
        "metadata": {
90
            "type": "object",
91
            "properties": {
92
                "name": {
93
                    "type": "string"
94
                },
95
                "namespace": {
96
                    "type": "string"
97
                }
98
            },
99
            "required": ["name"]
100
        }
101
    },
102
    "type": "object",
103
    "required": ["apiVersion", "kind"]
104
}
105
K8S_MINIMAL_RESOURCE_VALIDATOR = Draft7Validator(K8S_MINIMAL_RESOURCE_SCHEMA)
5✔
106

107
CLUSTER_RESOURCE_PATH = re.compile(r"^/apis?/(?:[^/]+/){1,2}([^/]+)$")
5✔
108
NAMESPACED_RESOURCE_PATH = re.compile(r"^/apis?/(?:[^/]+/){1,2}namespaces/[^/]+/([^/]+)$")
5✔
109

110
PROJECT_ANNOTATION = "kubernator.io/project"
5✔
111

112

113
class K8SResourcePatchType(Enum):
5✔
114
    JSON_PATCH = auto()
5✔
115
    SERVER_SIDE_PATCH = auto()
5✔
116

117

118
class K8SPropagationPolicy(Enum):
5✔
119
    BACKGROUND = ("Background",)
5✔
120
    FOREGROUND = ("Foreground",)
5✔
121
    ORPHAN = ("Orphan",)
5✔
122

123
    def __init__(self, policy):
5✔
124
        self.policy = policy
5✔
125

126

127
def is_integer(instance):
5✔
128
    # bool inherits from int, so ensure bools aren't reported as ints
129
    if isinstance(instance, bool):
5!
130
        return False
×
131
    return isinstance(instance, int)
5✔
132

133

134
def is_string(instance):
5✔
135
    return isinstance(instance, str)
5✔
136

137

138
def type_validator(validator, data_type, instance, schema):
5✔
139
    if instance is None:
5!
140
        return
×
141

142
    if data_type == "string" and schema.get("format") == "int-or-string":
5✔
143
        if not (is_string(instance) or is_integer(instance)):
5!
144
            yield ValidationError("%r is not of type %s" % (instance, "int-or-string"))
×
145
    elif not validator.is_type(instance, data_type):
5!
146
        yield ValidationError("%r is not of type %s" % (instance, data_type))
×
147

148

149
K8SValidator = extend(OAS31Validator, validators={
5✔
150
    "type": type_validator,
151
    "required": required
152
})
153

154
k8s_format_checker = FormatChecker()
5✔
155

156

157
@k8s_format_checker.checks("int32")
5✔
158
def check_int32(value):
5✔
159
    return value is not None and (-2147483648 < value < 2147483647)
5✔
160

161

162
@k8s_format_checker.checks("int64")
5✔
163
def check_int64(value):
5✔
164
    return value is not None and (-9223372036854775808 < value < 9223372036854775807)
5✔
165

166

167
@k8s_format_checker.checks("float")
5✔
168
def check_float(value):
5✔
169
    return value is not None and (-3.4E+38 < value < +3.4E+38)
×
170

171

172
@k8s_format_checker.checks("double")
5✔
173
def check_double(value):
5✔
174
    return value is not None and (-1.7E+308 < value < +1.7E+308)
5✔
175

176

177
@k8s_format_checker.checks("byte", ValueError)
5✔
178
def check_byte(value):
5✔
179
    if value is None:
5!
180
        return False
×
181
    base64.b64decode(value, validate=True)
5✔
182
    return True
5✔
183

184

185
@k8s_format_checker.checks("int-or-string")
5✔
186
def check_int_or_string(value):
5✔
187
    return check_int32(value) if is_integer(value) else is_string(value)
5✔
188

189

190
def to_group_and_version(api_version):
5✔
191
    group, _, version = api_version.partition("/")
5✔
192
    if not version:
5✔
193
        version = group
5✔
194
        group = ""
5✔
195
    return group, version
5✔
196

197

198
def to_k8s_resource_def_key(manifest):
5✔
199
    return K8SResourceDefKey(*to_group_and_version(manifest["apiVersion"]),
×
200
                             manifest["kind"])
201

202

203
class K8SResourceDefKey(namedtuple("K8SResourceDefKey", ["group", "version", "kind"])):
5✔
204
    __slots__ = ()
5✔
205

206
    def __str__(self):
5✔
207
        return f"{self.group}{'/' if self.group else '/'}{self.version}/{self.kind}"
5✔
208

209

210
class K8SResourceDef:
5✔
211
    def __init__(self, key, singular, plural, namespaced, custom, schema):
5✔
212
        self.key = key
5✔
213
        self.singular = singular
5✔
214
        self.plural = plural
5✔
215
        self.namespaced = namespaced
5✔
216
        self.custom = custom
5✔
217
        self.schema = schema
5✔
218

219
        self._api_get = None
5✔
220
        self._api_create = None
5✔
221
        self._api_patch = None
5✔
222
        self._api_delete = None
5✔
223
        self._api_list = None
5✔
224

225
    @property
5✔
226
    def group(self) -> str:
5✔
227
        return self.key.group
5✔
228

229
    @property
5✔
230
    def version(self) -> str:
5✔
231
        return self.key.version
5✔
232

233
    @property
5✔
234
    def kind(self) -> str:
5✔
235
        return self.key.kind
5✔
236

237
    @property
5✔
238
    def has_api(self) -> bool:
5✔
239
        return self.custom or self.plural
5✔
240

241
    @property
5✔
242
    def get(self):
5✔
243
        return self._api_get
5✔
244

245
    @property
5✔
246
    def create(self):
5✔
247
        return self._api_create
5✔
248

249
    @property
5✔
250
    def patch(self):
5✔
251
        return self._api_patch
5✔
252

253
    @property
5✔
254
    def delete(self):
5✔
255
        return self._api_delete
5✔
256

257
    @property
5✔
258
    def list(self):
5✔
259
        return self._api_list
5✔
260

261
    def __eq__(self, o: object) -> bool:
5✔
262
        if not isinstance(o, K8SResourceDef):
×
263
            return False
×
264

265
        return (self.key == o.key and
×
266
                self.singular == o.singular and
267
                self.plural == o.plural and
268
                self.namespaced == o.namespaced and
269
                self.custom == o.custom)
270

271
    def __hash__(self) -> int:
5✔
272
        return self.key.__hash__()
×
273

274
    def __str__(self):
5✔
275
        return f"{self.key=}, {self.singular=}, {self.plural=}, {self.namespaced=}, {self.custom=}"
×
276

277
    @classmethod
5✔
278
    def from_manifest(cls, key: K8SResourceDefKey,
5✔
279
                      schema,
280
                      paths: Mapping[K8SResourceDefKey, Mapping[str, Mapping]]):
281
        singular = key.kind.lower()
5✔
282

283
        plural = None
5✔
284
        namespaced = False
5✔
285

286
        if singular == "namespace":
5✔
287
            plural = "namespaces"
5✔
288
        else:
289
            for path in paths.get(key, ()):
5✔
290
                if m := NAMESPACED_RESOURCE_PATH.fullmatch(path):
5✔
291
                    plural = m[1]
5✔
292
                    namespaced = True
5✔
293
                    break
5✔
294
                elif m := CLUSTER_RESOURCE_PATH.fullmatch(path):
5✔
295
                    plural = m[1]
5✔
296

297
        yield K8SResourceDef(key, singular, plural, namespaced, False, schema)
5✔
298

299
    @classmethod
5✔
300
    def from_resource(cls, resource: "K8SResource"):
5✔
301
        manifest = resource.manifest
5✔
302
        spec = manifest["spec"]
5✔
303
        group = spec["group"]
5✔
304
        names = spec["names"]
5✔
305
        kind = names["kind"]
5✔
306
        singular = names.get("singular", names["kind"].lower())
5✔
307
        plural = names["plural"]
5✔
308
        namespaced = spec["scope"] == "Namespaced"
5✔
309

310
        for version_spec in spec["versions"]:
5✔
311
            version = version_spec["name"]
5✔
312
            if resource.version == "v1":
5!
313
                schema = version_spec["schema"]["openAPIV3Schema"]
5✔
314
            else:
315
                schema = spec["validation"]["openAPIV3Schema"]
×
316
            yield K8SResourceDef(K8SResourceDefKey(group, version, kind), singular, plural, namespaced, True, schema)
5✔
317

318
    def populate_api(self, k8s_client_module, k8s_client):
5✔
319
        if not self.has_api:
5!
320
            raise RuntimeError(f"{self} has no API")
×
321

322
        if self._api_get:
5✔
323
            return
5✔
324

325
        group = self.group or "core"
5✔
326
        version = self.version
5✔
327
        kind = self.kind
5✔
328

329
        if self.custom:
5✔
330
            k8s_api = k8s_client_module.CustomObjectsApi(k8s_client)
5✔
331

332
            kwargs = {"group": group,
5✔
333
                      "version": version,
334
                      "plural": self.plural}
335
            if self.namespaced:
5!
336
                self._api_get = partial(k8s_api.get_namespaced_custom_object, **kwargs)
5✔
337
                self._api_patch = partial(k8s_api.patch_namespaced_custom_object, **kwargs)
5✔
338
                self._api_create = partial(k8s_api.create_namespaced_custom_object, **kwargs)
5✔
339
                self._api_delete = partial(k8s_api.delete_namespaced_custom_object, **kwargs)
5✔
340
                self._api_list = partial(k8s_api.list_namespaced_custom_object, **kwargs)
5✔
341
            else:
342
                self._api_get = partial(k8s_api.get_cluster_custom_object, **kwargs)
×
343
                self._api_patch = partial(k8s_api.patch_cluster_custom_object, **kwargs)
×
344
                self._api_create = partial(k8s_api.create_cluster_custom_object, **kwargs)
×
345
                self._api_delete = partial(k8s_api.delete_cluster_custom_object, **kwargs)
×
346
                self._api_list = partial(k8s_api.list_cluster_custom_object, **kwargs)
×
347
        else:
348
            # Take care for the case e.g. api_type is "apiextensions.k8s.io"
349
            # Only replace the last instance
350
            group = "".join(group.rsplit(".k8s.io", 1))
5✔
351

352
            # convert group name from DNS subdomain format to
353
            # python class name convention
354
            group = "".join(word.capitalize() for word in group.split('.'))
5✔
355
            fcn_to_call = f"{group}{version.capitalize()}Api"
5✔
356
            k8s_api = getattr(k8s_client_module, fcn_to_call)(k8s_client)
5✔
357

358
            # Replace CamelCased action_type into snake_case
359
            kind = UPPER_FOLLOWED_BY_LOWER_RE.sub(r"\1_\2", kind)
5✔
360
            kind = LOWER_OR_NUM_FOLLOWED_BY_UPPER_RE.sub(r"\1_\2", kind).lower()
5✔
361

362
            if self.namespaced:
5✔
363
                self._api_get = getattr(k8s_api, f"read_namespaced_{kind}")
5✔
364
                self._api_patch = getattr(k8s_api, f"patch_namespaced_{kind}")
5✔
365
                self._api_create = getattr(k8s_api, f"create_namespaced_{kind}")
5✔
366
                self._api_delete = getattr(k8s_api, f"delete_namespaced_{kind}")
5✔
367
                self._api_list = getattr(k8s_api, f"list_namespaced_{kind}")
5✔
368
            else:
369
                self._api_get = getattr(k8s_api, f"read_{kind}")
5✔
370
                self._api_patch = getattr(k8s_api, f"patch_{kind}")
5✔
371
                self._api_create = getattr(k8s_api, f"create_{kind}")
5✔
372
                self._api_delete = getattr(k8s_api, f"delete_{kind}")
5✔
373
                self._api_list = getattr(k8s_api, f"list_{kind}")
5✔
374

375

376
class K8SResourceKey(namedtuple("K8SResourceKey", ["group", "kind", "name", "namespace"])):
5✔
377
    __slots__ = ()
5✔
378

379
    def __str__(self):
5✔
UNCOV
380
        return (f"{self.group}{'/' if self.group else 'v1/'}{self.kind}"
×
381
                f"/{self.name}{f'.{self.namespace}' if self.namespace else ''}")
382

383

384
class K8SResource:
5✔
385
    _k8s_client_version = None
5✔
386
    _k8s_field_validation = None
5✔
387
    _k8s_field_validation_patched = None
5✔
388
    _logger = None
5✔
389
    _api_warnings = None
5✔
390

391
    def __init__(self, manifest: dict, rdef: K8SResourceDef, source: Union[str, Path] = None):
5✔
392
        self.key = self.get_manifest_key(manifest)
5✔
393

394
        self.manifest = manifest
5✔
395
        self.rdef = rdef
5✔
396
        self.source = source
5✔
397

398
    @property
5✔
399
    def group(self) -> str:
5✔
400
        return self.key.group
5✔
401

402
    @property
5✔
403
    def version(self) -> str:
5✔
404
        return self.rdef.version
5✔
405

406
    @property
5✔
407
    def kind(self) -> str:
5✔
408
        return self.key.kind
5✔
409

410
    @property
5✔
411
    def name(self) -> str:
5✔
412
        return self.key.name
5✔
413

414
    @name.setter
5✔
415
    def name(self, value):
5✔
UNCOV
416
        self.manifest["metadata"]["name"] = value
×
UNCOV
417
        self.key = self.get_manifest_key(self.manifest)
×
418

419
    @property
5✔
420
    def namespace(self) -> Optional[str]:
5✔
421
        return self.key.namespace
5✔
422

423
    @namespace.setter
5✔
424
    def namespace(self, value):
5✔
UNCOV
425
        self.manifest["metadata"]["namespace"] = value
×
UNCOV
426
        self.key = self.get_manifest_key(self.manifest)
×
427

428
    @property
5✔
429
    def api_version(self) -> str:
5✔
430
        return self.manifest["apiVersion"]
5✔
431

432
    @property
5✔
433
    def schema(self) -> dict:
5✔
UNCOV
434
        return self.rdef.schema
×
435

436
    @property
5✔
437
    def is_crd(self):
5✔
438
        return self.group == "apiextensions.k8s.io" and self.kind == "CustomResourceDefinition"
5✔
439

440
    @property
5✔
441
    def project(self) -> Optional[str]:
5✔
442
        metadata = self.manifest.get("metadata") or {}
5✔
443
        annotations = metadata.get("annotations") or {}
5✔
444
        return annotations.get(PROJECT_ANNOTATION)
5✔
445

446
    def __str__(self):
5✔
447
        return f"{self.api_version}/{self.kind}/{self.name}{'.' + self.namespace if self.namespace else ''}"
5✔
448

449
    @_normalize_api_exc
5✔
450
    def get(self):
5✔
451
        rdef = self.rdef
5✔
452
        kwargs = {"name": self.name,
5✔
453
                  "_preload_content": False}
454
        if rdef.namespaced:
5✔
455
            kwargs["namespace"] = self.namespace
5✔
456
        return json.loads(self.rdef.get(**kwargs).data)
5✔
457

458
    @_normalize_api_exc
5✔
459
    def create(self, dry_run=True):
5✔
460
        rdef = self.rdef
5✔
461
        kwargs = {"body": self.manifest,
5✔
462
                  "_preload_content": False,
463
                  "field_manager": "kubernator",
464
                  }
465

466
        # `and not self.rdef.custom` to be removed after solving https://github.com/kubernetes-client/gen/issues/259
467
        if self._k8s_field_validation_patched or not self.rdef.custom:
5!
468
            kwargs["field_validation"] = self._k8s_field_validation
5✔
469
        if rdef.namespaced:
5✔
470
            kwargs["namespace"] = self.namespace
5✔
471
        if dry_run:
5✔
472
            kwargs["dry_run"] = "All"
5✔
473
        resp = rdef.create(**kwargs)
5✔
474
        self._process_response_headers(resp)
5✔
475
        return json.loads(resp.data)
5✔
476

477
    @_normalize_api_exc
5✔
478
    def patch(self, json_patch, *, patch_type: K8SResourcePatchType, force=False, dry_run=True):
5✔
479
        rdef = self.rdef
5✔
480
        kwargs = {"name": self.name,
5✔
481
                  "body": json_patch,
482
                  "_preload_content": False,
483
                  "field_manager": "kubernator",
484
                  }
485

486
        # `and not self.rdef.custom` to be removed after solving https://github.com/kubernetes-client/gen/issues/259
487
        if self._k8s_field_validation_patched or not self.rdef.custom:
5!
488
            kwargs["field_validation"] = self._k8s_field_validation
5✔
489
        if patch_type == K8SResourcePatchType.SERVER_SIDE_PATCH:
5✔
490
            kwargs["force"] = force
5✔
491
        if rdef.namespaced:
5✔
492
            kwargs["namespace"] = self.namespace
5✔
493
        if dry_run:
5✔
494
            kwargs["dry_run"] = "All"
5✔
495

496
        def select_header_content_type_patch(content_types):
5✔
497
            if patch_type == K8SResourcePatchType.JSON_PATCH:
5✔
498
                return "application/json-patch+json"
5✔
499
            if patch_type == K8SResourcePatchType.SERVER_SIDE_PATCH:
5!
500
                return "application/apply-patch+yaml"
5✔
501
            raise NotImplementedError
×
502

503
        if isinstance(rdef.patch, partial):
5!
504
            api_client = rdef.patch.func.__self__.api_client
×
505
        else:
506
            api_client = rdef.patch.__self__.api_client
5✔
507

508
        old_func = api_client.select_header_content_type
5✔
509
        try:
5✔
510
            api_client.select_header_content_type = select_header_content_type_patch
5✔
511
            resp = rdef.patch(**kwargs)
5✔
512
            self._process_response_headers(resp)
5✔
513
            return json.loads(resp.data)
5✔
514
        finally:
515
            api_client.select_header_content_type = old_func
5✔
516

517
    @_normalize_api_exc
5✔
518
    def delete(self, *, dry_run=True, propagation_policy=K8SPropagationPolicy.BACKGROUND, wait=True):
5✔
519
        from kubernetes.client import ApiException
5✔
520
        rdef = self.rdef
5✔
521
        kwargs = {"name": self.name,
5✔
522
                  "_preload_content": False,
523
                  "propagation_policy": propagation_policy.policy
524
                  }
525
        if rdef.namespaced:
5!
526
            kwargs["namespace"] = self.namespace
5✔
527
        if dry_run:
5✔
528
            kwargs["dry_run"] = "All"
5✔
529

530
        result = json.loads(rdef.delete(**kwargs).data)
5✔
531

532
        if wait and not dry_run:
5✔
533
            # Wait for the resource to actually disappear by watching for the
534
            # DELETED event. If the resource is already gone before the watch
535
            # opens (race), the watch produces no events; loop with a short
536
            # server-side timeout and re-check existence via get() to break out.
537
            while True:
5✔
538
                for event in self.watch(timeout_seconds=10):
5✔
539
                    if event["type"] == "DELETED":
4✔
540
                        return result
4✔
541
                try:
1✔
542
                    self.get()
1✔
543
                except ApiException as e:
1✔
544
                    if e.status == 404:
1!
545
                        return result
1✔
546
                    raise
×
547

548
        return result
5✔
549

550
    def watch(self, *, timeout_seconds=None):
5✔
551
        from kubernetes import watch as k8s_watch
5✔
552
        rdef = self.rdef
5✔
553
        kwargs = {"field_selector": f"metadata.name={self.name}"}
5✔
554
        if rdef.namespaced:
5!
555
            kwargs["namespace"] = self.namespace
5✔
556
        if timeout_seconds is not None:
5!
557
            kwargs["timeout_seconds"] = timeout_seconds
5✔
558
        return k8s_watch.Watch().stream(rdef.list, **kwargs)
5✔
559

560
    @staticmethod
5✔
561
    def get_manifest_key(manifest):
5✔
562
        return K8SResourceKey(to_group_and_version(manifest["apiVersion"])[0],
5✔
563
                              manifest["kind"],
564
                              manifest["metadata"]["name"],
565
                              manifest["metadata"].get("namespace"))
566

567
    @staticmethod
5✔
568
    def get_manifest_description(manifest: dict, source=None):
5✔
569
        api_version = manifest.get("apiVersion")
5✔
570
        kind = manifest.get("kind")
5✔
571
        metadata = manifest.get("metadata")
5✔
572
        name = None
5✔
573
        namespace = None
5✔
574
        if metadata:
5!
575
            name = metadata.get("name")
5✔
576
            namespace = metadata.get("namespace")
5✔
577
        return (f"{api_version or 'unknown'}/{kind or '<unknown>'}/"
5✔
578
                f"{name or '<unknown>'}{'.' + namespace if namespace else ''}")
579

580
    def __eq__(self, other):
5✔
581
        if not isinstance(other, K8SResource):
×
582
            return False
×
583
        return self.key == other.key and self.manifest == other.manifest
×
584

585
    def _process_response_headers(self, resp):
5✔
586
        headers = resp.headers
5✔
587
        warn_headers = headers.get("Warning")
5✔
588
        if warn_headers:
5✔
589
            for warn in K8S_WARNING_HEADER.findall(warn_headers):
5✔
590
                code, _, msg, _ = warn
5✔
591
                code = int(code)
5✔
592
                msg = msg.encode("utf-8").decode("unicode_escape")
5✔
593
                if code == 299:
5!
594
                    self._api_warnings(self, msg)
5✔
595
                else:
596
                    self._logger.warning("Unknown API warning received for resource %s from %s: code %d: %s",
×
597
                                         self, self.source, code, msg)
598

599

600
class K8SResourcePluginMixin:
5✔
601
    def __init__(self):
5✔
602
        self.resource_definitions: MutableMapping[K8SResourceDefKey, K8SResourceDef] = {}
5✔
603
        self.resource_paths: MutableMapping[K8SResourceDefKey, MutableMapping[str, dict]] = {}
5✔
604
        self.resources: MutableMapping[K8SResourceKey, K8SResource] = {}
5✔
605

606
        self.resource_definitions_schema = None
5✔
607

608
    def add_resources(self, manifests: Union[str, list, dict], source: Union[str, Path] = None):
5✔
609
        if not source:
5✔
610
            source = calling_frame_source()
5✔
611

612
        if isinstance(manifests, str):
5✔
613
            manifests = list(parse_yaml_docs(manifests, source))
5✔
614

615
        if isinstance(manifests, (Mapping, dict)):
5!
616
            return self.add_resource(manifests, source)
×
617
        else:
618
            return [self.add_resource(m, source) for m in manifests if m]
5✔
619

620
    def add_resource(self, manifest: dict, source: Union[str, Path] = None):
5✔
621
        if not source:
5!
622
            source = calling_frame_source()
×
623
        resource = self._create_resource(manifest, source)
5✔
624

625
        try:
5✔
626
            trans_resource = self._transform_resource(list(self.resources.values()), resource)
5✔
627
        except Exception as e:
×
628
            self.logger.error("An error occurred running transformers on %s", resource, exc_info=e)
×
629
            raise
×
630

631
        errors = list(self._validate_resource(trans_resource.manifest, source))
5✔
632
        if errors:
5!
633
            for error in errors:
×
634
                if source:
×
635
                    self.logger.error("Error detected in re-transformed K8S resource %s generated through %s",
×
636
                                      trans_resource, source, exc_info=error)
637
            raise errors[0]
×
638

639
        return self._add_resource(trans_resource, source)
5✔
640

641
    def add_crds(self, manifests: Union[str, list, dict], source: Union[str, Path] = None):
5✔
642
        if not source:
×
643
            source = calling_frame_source()
×
644

645
        if isinstance(manifests, str):
×
646
            manifests = list(parse_yaml_docs(manifests, source))
×
647

648
        if isinstance(manifests, (Mapping, dict)):
×
649
            return self.add_crd(manifests, source)
×
650
        else:
651
            return [self.add_crd(m, source) for m in manifests if m]
×
652

653
    def add_crd(self, manifest: dict, source: Union[str, Path] = None):
5✔
654
        if not source:
5!
655
            source = calling_frame_source()
×
656
        resource = self._create_resource(manifest, source)
5✔
657
        if not resource.is_crd:
5!
658
            resource_description = K8SResource.get_manifest_description(manifest, source)
×
659
            raise ValueError(f"K8S manifest {resource_description} from {source} is not a CRD")
×
660

661
        self._add_crd(resource)
5✔
662
        return resource
5✔
663

664
    def create_resource(self, manifest: dict, source: Union[str, Path] = None):
5✔
665
        """Create K8S resource without adding it"""
666
        if not source:
×
667
            source = calling_frame_source()
×
668

669
        return self._create_resource(manifest, source)
×
670

671
    def add_local_resources(self, path: Path, file_type: FileType, source: str = None):
5✔
672
        manifests = load_file(self.logger, path, file_type)
×
673

674
        return [self.add_resource(m, source or path) for m in manifests if m]
×
675

676
    def add_remote_resources(self, url: str, file_type: FileType, *, sub_category: Optional[str] = None,
5✔
677
                             source: str = None):
678
        manifests = load_remote_file(self.logger, url, file_type, sub_category=sub_category)
×
679

680
        return [self.add_resource(m, source or url) for m in manifests if m]
×
681

682
    def add_local_crds(self, path: Path, file_type: FileType, source: str = None):
5✔
683
        manifests = load_file(self.logger, path, file_type)
5✔
684

685
        return [self.add_crd(m, source or path) for m in manifests if m]
5✔
686

687
    def add_remote_crds(self, url: str, file_type: FileType, *, sub_category: Optional[str] = None,
5✔
688
                        source: str = None):
689
        manifests = load_remote_file(self.logger, url, file_type, sub_category=sub_category)
5✔
690

691
        return [self.add_crd(m, source or url) for m in manifests if m]
5✔
692

693
    def get_api_versions(self):
5✔
694
        api_versions = set()
5✔
695
        for rdef in self.resource_definitions:
5✔
696
            api_version = f"{f'{rdef.group}/' if rdef.group else ''}{rdef.version}"
5✔
697
            if api_version not in api_versions:
5✔
698
                api_versions.add(api_version)
5✔
699
        return sorted(api_versions)
5✔
700

701
    def _create_resource(self, manifest: dict, source: Union[str, Path] = None):
5✔
702
        resource_description = K8SResource.get_manifest_description(manifest, source)
5✔
703

704
        new_manifest = self._patch_manifest(manifest, resource_description)
5✔
705
        if new_manifest != manifest:
5!
706
            manifest = new_manifest
×
707
            resource_description = K8SResource.get_manifest_description(manifest, source)
×
708

709
        self.logger.debug("Validating K8S manifest for %s", resource_description)
5✔
710
        errors = list(self._validate_resource(manifest, source))
5✔
711
        if errors:
5!
712
            for error in errors:
×
713
                self.logger.error("Error detected in K8S manifest %s from %s: \n%s",
×
714
                                  resource_description, source or "<unknown>", yaml.safe_dump(manifest, None),
715
                                  exc_info=error)
716
            raise errors[0]
×
717

718
        rdef = self._get_manifest_rdef(manifest)
5✔
719
        return K8SResource(manifest, rdef, source)
5✔
720

721
    def _add_resource(self, resource: K8SResource, source):
5✔
722
        if resource.key in self.resources:
5!
723
            existing_resource = self.resources[resource.key]
×
724
            if resource != existing_resource:
×
725
                raise ValidationError("resource %s from %s already exists and was added from %s" %
×
726
                                      (resource.key, resource.source, existing_resource.source))
727
            self.logger.trace("K8S resource for %s from %s is already present and is identical", resource, source)
×
728
            return existing_resource
×
729

730
        self.logger.info("Adding K8S resource for %s from %s", resource, source)
5✔
731
        self.resources[resource.key] = resource
5✔
732

733
        if resource.is_crd:
5✔
734
            self._add_crd(resource)
5✔
735

736
        return resource
5✔
737

738
    def _patch_manifest(self,
5✔
739
                        manifest: dict,
740
                        resource_description: str):
741
        return manifest
5✔
742

743
    def _transform_resource(self,
5✔
744
                            resources: Sequence[K8SResource],
745
                            resource: K8SResource) -> K8SResource:
746
        return resource
5✔
747

748
    def _filter_resources(self, func: Callable[[K8SResource], bool]):
5✔
749
        yield from filter(func, self.resources.values())
×
750

751
    def _validate_resource(self, manifest: dict, source: Union[str, Path] = None):
5✔
752
        for error in self._yield_manifest_rdef(manifest):
5✔
753
            if isinstance(error, Exception):
5!
754
                yield error
×
755
            else:
756
                rdef = error
5✔
757
                k8s_validator = K8SValidator(rdef.schema,
5✔
758
                                             format_checker=k8s_format_checker)
759
                yield from k8s_validator.iter_errors(manifest)
5✔
760

761
    def _get_manifest_rdef(self, manifest):
5✔
762
        for error in self._yield_manifest_rdef(manifest):
5!
763
            if isinstance(error, Exception):
5!
764
                raise error
×
765
            else:
766
                return error
5✔
767

768
    def _yield_manifest_rdef(self, manifest):
5✔
769
        error = None
5✔
770
        for error in K8S_MINIMAL_RESOURCE_VALIDATOR.iter_errors(manifest):
5!
771
            yield error
×
772

773
        if error:
5!
774
            return
×
775

776
        key = K8SResourceDefKey(*to_group_and_version(manifest["apiVersion"]), manifest["kind"])
5✔
777

778
        try:
5✔
779
            yield self.resource_definitions[key]
5✔
780
        except KeyError:
5✔
781
            yield ValidationError("%s is not a defined Kubernetes resource" % (key,),
×
782
                                  validator=K8S_MINIMAL_RESOURCE_VALIDATOR,
783
                                  validator_value=key,
784
                                  instance=manifest,
785
                                  schema=K8S_MINIMAL_RESOURCE_SCHEMA)
786

787
    def _add_crd(self, resource: K8SResource):
5✔
788
        for crd in K8SResourceDef.from_resource(resource):
5✔
789
            self.logger.info("Adding K8S CRD definition %s", crd.key)
5✔
790
            self.resource_definitions[crd.key] = crd
5✔
791

792
    def _populate_resource_definitions(self):
5✔
793
        k8s_def = self.resource_definitions_schema
5✔
794

795
        def k8s_resource_def_key(v: Mapping[str, Union[list, Mapping]]) -> Iterable[K8SResourceDefKey]:
5✔
796
            gvks = v.get("x-kubernetes-group-version-kind")
5✔
797
            if gvks:
5✔
798
                if isinstance(gvks, Mapping):
5✔
799
                    gvk = gvks
5✔
800
                    yield K8SResourceDefKey(gvk["group"],
5✔
801
                                            gvk["version"],
802
                                            gvk["kind"])
803
                else:
804
                    for gvk in gvks:
5✔
805
                        yield K8SResourceDefKey(gvk["group"],
5✔
806
                                                gvk["version"],
807
                                                gvk["kind"])
808

809
        paths = k8s_def["paths"]
5✔
810
        for path, actions in paths.items():
5✔
811
            path_rdk = None
5✔
812
            path_actions = []
5✔
813
            for action, action_details in actions.items():
5✔
814
                if action == "parameters":
5✔
815
                    continue
5✔
816
                rdks = list(k8s_resource_def_key(action_details))
5✔
817
                if rdks:
5✔
818
                    assert len(rdks) == 1
5✔
819
                    rdk = rdks[0]
5✔
820
                    if path_rdk:
5✔
821
                        if path_rdk != rdk:
5!
822
                            raise ValueError(f"Encountered path action x-kubernetes-group-version-kind conflict: "
×
823
                                             f"{path}: {actions}")
824
                        path_actions.append(action_details["x-kubernetes-action"])
5✔
825
                    else:
826
                        path_rdk = rdk
5✔
827

828
            if path_rdk:
5✔
829
                rdef_paths = self.resource_paths.get(path_rdk)
5✔
830
                if not rdef_paths:
5✔
831
                    rdef_paths = {}
5✔
832
                    self.resource_paths[path_rdk] = rdef_paths
5✔
833
                rdef_paths[path] = actions
5✔
834

835
        for k, schema in k8s_def["definitions"].items():
5✔
836
            # This short-circuits the resolution of the references to the top of the document
837
            schema["definitions"] = k8s_def["definitions"]
5✔
838
            for key in k8s_resource_def_key(schema):
5✔
839
                for rdef in K8SResourceDef.from_manifest(key, schema, self.resource_paths):
5✔
840
                    self.resource_definitions[key] = rdef
5✔
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