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

karellen / kubernator / 15126454653

20 May 2025 01:02AM UTC coverage: 76.274% (+0.03%) from 76.249%
15126454653

push

github

arcivanov
Release v1.0.20

615 of 951 branches covered (64.67%)

Branch coverage included in aggregate %.

2378 of 2973 relevant lines covered (79.99%)

3.99 hits per line

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

79.82
/src/main/python/kubernator/plugins/k8s.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

20
import json
5✔
21
import logging
5✔
22
import re
5✔
23
import sys
5✔
24
import types
5✔
25
from collections.abc import Mapping
5✔
26
from functools import partial
5✔
27
from importlib.metadata import version as pkg_version
5✔
28
from pathlib import Path
5✔
29
from typing import Iterable, Callable, Sequence
5✔
30

31
import jsonpatch
5✔
32
import yaml
5✔
33

34
from kubernator.api import (KubernatorPlugin,
5✔
35
                            Globs,
36
                            scan_dir,
37
                            load_file,
38
                            FileType,
39
                            load_remote_file,
40
                            StripNL,
41
                            install_python_k8s_client)
42
from kubernator.merge import extract_merge_instructions, apply_merge_instructions
5✔
43
from kubernator.plugins.k8s_api import (K8SResourcePluginMixin,
5✔
44
                                        K8SResource,
45
                                        K8SResourcePatchType,
46
                                        K8SPropagationPolicy)
47

48
logger = logging.getLogger("kubernator.k8s")
5✔
49
proc_logger = logger.getChild("proc")
5✔
50
stdout_logger = StripNL(proc_logger.info)
5✔
51
stderr_logger = StripNL(proc_logger.warning)
5✔
52

53
FIELD_VALIDATION_STRICT_MARKER = "strict decoding error: "
5✔
54
VALID_FIELD_VALIDATION = ("Ignore", "Warn", "Strict")
5✔
55

56

57
def final_resource_validator(resources: Sequence[K8SResource],
5✔
58
                             resource: K8SResource,
59
                             error: Callable[..., Exception]) -> Iterable[Exception]:
60
    final_key = resource.get_manifest_key(resource.manifest)
5✔
61
    if final_key != resource.key:
5!
62
        yield error("Illegal change of identifiers of the resource "
×
63
                    "%s from %s have been changed to %s",
64
                    resource.key, resource.source, final_key)
65

66
    if resource.rdef.namespaced and not resource.namespace:
5!
67
        yield error("Namespaced resource %s from %s is missing the required namespace",
×
68
                    resource, resource.source)
69

70

71
def normalize_pkg_version(v: str):
5✔
72
    v_split = v.split(".")
5✔
73
    rev = v_split[-1]
5✔
74
    if not rev.isdigit():
5✔
75
        new_rev = ""
5✔
76
        for c in rev:
5!
77
            if not c.isdigit():
5✔
78
                break
5✔
79
            new_rev += c
5✔
80
        v_split[-1] = new_rev
5✔
81
    return tuple(map(int, v_split))
5✔
82

83

84
class KubernetesPlugin(KubernatorPlugin, K8SResourcePluginMixin):
5✔
85
    logger = logger
5✔
86

87
    _name = "k8s"
5✔
88

89
    def __init__(self):
5✔
90
        super().__init__()
5✔
91
        self.context = None
5✔
92

93
        self.embedded_pkg_version = self._get_kubernetes_client_version()
5✔
94

95
        self._transformers = []
5✔
96
        self._validators = []
5✔
97
        self._summary = 0, 0, 0
5✔
98

99
    def set_context(self, context):
5✔
100
        self.context = context
5✔
101

102
    def register(self,
5✔
103
                 field_validation="Warn",
104
                 field_validation_warn_fatal=True,
105
                 disable_client_patches=False):
106
        self.context.app.register_plugin("kubeconfig")
5✔
107

108
        if field_validation not in VALID_FIELD_VALIDATION:
5!
109
            raise ValueError("'field_validation' must be one of %s" % (", ".join(VALID_FIELD_VALIDATION)))
×
110

111
        context = self.context
5✔
112
        context.globals.k8s = dict(patch_field_excludes=("^/metadata/managedFields",
5✔
113
                                                         "^/metadata/generation",
114
                                                         "^/metadata/creationTimestamp",
115
                                                         "^/metadata/resourceVersion",
116
                                                         ),
117
                                   immutable_changes={("apps", "DaemonSet"): K8SPropagationPolicy.BACKGROUND,
118
                                                      ("apps", "StatefulSet"): K8SPropagationPolicy.ORPHAN,
119
                                                      ("apps", "Deployment"): K8SPropagationPolicy.ORPHAN,
120
                                                      ("storage.k8s.io", "StorageClass"): K8SPropagationPolicy.ORPHAN,
121
                                                      },
122
                                   default_includes=Globs(["*.yaml", "*.yml"], True),
123
                                   default_excludes=Globs([".*"], True),
124
                                   add_resources=self.add_resources,
125
                                   load_resources=self.api_load_resources,
126
                                   load_remote_resources=self.api_load_remote_resources,
127
                                   load_crds=self.api_load_crds,
128
                                   load_remote_crds=self.api_load_remote_crds,
129
                                   add_transformer=self.api_add_transformer,
130
                                   remove_transformer=self.api_remove_transformer,
131
                                   add_validator=self.api_remove_validator,
132
                                   get_api_versions=self.get_api_versions,
133
                                   create_resource=self.create_resource,
134
                                   disable_client_patches=disable_client_patches,
135
                                   field_validation=field_validation,
136
                                   field_validation_warn_fatal=field_validation_warn_fatal,
137
                                   field_validation_warnings=0,
138
                                   _k8s=self,
139
                                   )
140
        context.k8s = dict(default_includes=Globs(context.globals.k8s.default_includes),
5✔
141
                           default_excludes=Globs(context.globals.k8s.default_excludes)
142
                           )
143
        self.api_add_validator(final_resource_validator)
5✔
144

145
    def handle_init(self):
5✔
146
        pass
5✔
147

148
    def handle_start(self):
5✔
149
        self.context.kubeconfig.register_change_notifier(self._kubeconfig_changed)
5✔
150
        self.setup_client()
5✔
151

152
    def _kubeconfig_changed(self):
5✔
153
        self.setup_client()
×
154

155
    def _get_kubernetes_client_version(self):
5✔
156
        return pkg_version("kubernetes").split(".")
5✔
157

158
    def setup_client(self):
5✔
159
        k8s = self.context.k8s
5✔
160
        if "server_version" not in k8s:
5!
161
            self._setup_client()
5✔
162

163
        server_minor = k8s.server_version[1]
5✔
164

165
        logger.info("Using Kubernetes client version =~%s.0 for server version %s",
5✔
166
                    server_minor, ".".join(k8s.server_version))
167
        pkg_dir = install_python_k8s_client(self.context.app.run, server_minor, logger,
5✔
168
                                            stdout_logger, stderr_logger, k8s.disable_client_patches)
169

170
        modules_to_delete = []
5✔
171
        for k, v in sys.modules.items():
5✔
172
            if k == "kubernetes" or k.startswith("kubernetes."):
5✔
173
                modules_to_delete.append(k)
5✔
174
        for k in modules_to_delete:
5✔
175
            del sys.modules[k]
5✔
176

177
        logger.info("Adding sys.path reference to %s", pkg_dir)
5✔
178
        sys.path.insert(0, str(pkg_dir))
5✔
179
        self.embedded_pkg_version = self._get_kubernetes_client_version()
5✔
180
        logger.info("Switching to Kubernetes client version %s", ".".join(self.embedded_pkg_version))
5✔
181
        self._setup_client()
5✔
182

183
        logger.debug("Reading Kubernetes OpenAPI spec for %s", k8s.server_git_version)
5✔
184

185
        k8s_def = load_remote_file(logger, f"https://raw.githubusercontent.com/kubernetes/kubernetes/"
5✔
186
                                           f"{k8s.server_git_version}/api/openapi-spec/swagger.json",
187
                                   FileType.JSON)
188
        self.resource_definitions_schema = k8s_def
5✔
189

190
        self._populate_resource_definitions()
5✔
191

192
    def _setup_client(self):
5✔
193
        from kubernetes import client
5✔
194

195
        context = self.context
5✔
196
        k8s = context.k8s
5✔
197

198
        k8s.client = self._setup_k8s_client()
5✔
199
        version = client.VersionApi(k8s.client).get_code()
5✔
200
        if "-eks-" in version.git_version:
5!
201
            git_version = version.git_version.split("-")[0]
×
202
        else:
203
            git_version = version.git_version
5✔
204

205
        k8s.server_version = git_version[1:].split(".")
5✔
206
        k8s.server_git_version = git_version
5✔
207

208
        logger.info("Found Kubernetes %s on %s", k8s.server_git_version, k8s.client.configuration.host)
5✔
209

210
        K8SResource._k8s_client_version = normalize_pkg_version(pkg_version("kubernetes"))
5✔
211
        K8SResource._k8s_field_validation = k8s.field_validation
5✔
212
        K8SResource._k8s_field_validation_patched = not k8s.disable_client_patches
5✔
213
        K8SResource._logger = self.logger
5✔
214
        K8SResource._api_warnings = self._api_warnings
5✔
215

216
    def _api_warnings(self, resource, warn):
5✔
217
        k8s = self.context.k8s
5✔
218
        self.context.globals.k8s.field_validation_warnings += 1
5✔
219

220
        log = self.logger.warning
5✔
221
        if k8s.field_validation_warn_fatal:
5✔
222
            log = self.logger.error
5✔
223

224
        log("FAILED FIELD VALIDATION on resource %s from %s: %s", resource, resource.source, warn)
5✔
225

226
    def handle_before_dir(self, cwd: Path):
5✔
227
        context = self.context
5✔
228
        context.k8s.default_includes = Globs(context.k8s.default_includes)
5✔
229
        context.k8s.default_excludes = Globs(context.k8s.default_excludes)
5✔
230
        context.k8s.includes = Globs(context.k8s.default_includes)
5✔
231
        context.k8s.excludes = Globs(context.k8s.default_excludes)
5✔
232

233
    def handle_after_dir(self, cwd: Path):
5✔
234
        context = self.context
5✔
235
        k8s = context.k8s
5✔
236

237
        for f in scan_dir(logger, cwd, lambda d: d.is_file(), k8s.excludes, k8s.includes):
5✔
238
            p = cwd / f.name
5✔
239
            display_p = context.app.display_path(p)
5✔
240
            logger.debug("Adding Kubernetes manifest from %s", display_p)
5✔
241

242
            manifests = load_file(logger, p, FileType.YAML, display_p)
5✔
243

244
            for manifest in manifests:
5✔
245
                if manifest:
5!
246
                    self.add_resource(manifest, display_p)
5✔
247

248
    def handle_apply(self):
5✔
249
        context = self.context
5✔
250
        k8s = context.k8s
5✔
251

252
        self._validate_resources()
5✔
253

254
        cmd = context.app.args.command
5✔
255
        file = context.app.args.file
5✔
256
        file_format = context.app.args.output_format
5✔
257
        dry_run = context.app.args.dry_run
5✔
258
        dump = cmd == "dump"
5✔
259

260
        status_msg = f"{' (dump only)' if dump else ' (dry run)' if dry_run else ''}"
5✔
261
        if dump:
5✔
262
            logger.info("Will dump the changes into a file %s in %s format", file, file_format)
5✔
263

264
        patch_field_excludes = [re.compile(e) for e in context.globals.k8s.patch_field_excludes]
5✔
265
        dump_results = []
5✔
266
        total_created, total_patched, total_deleted = 0, 0, 0
5✔
267
        for resource in self.resources.values():
5✔
268
            if dump:
5✔
269
                resource_id = {"apiVersion": resource.api_version,
5✔
270
                               "kind": resource.kind,
271
                               "name": resource.name
272
                               }
273

274
                def patch_func(patch):
5✔
275
                    if resource.rdef.namespaced:
5!
276
                        resource_id["namespace"] = resource.namespace
×
277
                    method_descriptor = {"method": "patch",
5✔
278
                                         "resource": resource_id,
279
                                         "body": patch
280
                                         }
281
                    dump_results.append(method_descriptor)
5✔
282

283
                def create_func():
5✔
284
                    method_descriptor = {"method": "create",
5✔
285
                                         "body": resource.manifest}
286
                    dump_results.append(method_descriptor)
5✔
287

288
                def delete_func(*, propagation_policy):
5✔
289
                    method_descriptor = {"method": "delete",
×
290
                                         "resource": resource_id,
291
                                         "propagation_policy": propagation_policy.policy
292
                                         }
293
                    dump_results.append(method_descriptor)
×
294
            else:
295
                patch_func = partial(resource.patch, patch_type=K8SResourcePatchType.JSON_PATCH, dry_run=dry_run)
5✔
296
                create_func = partial(resource.create, dry_run=dry_run)
5✔
297
                delete_func = partial(resource.delete, dry_run=dry_run)
5✔
298

299
            created, patched, deleted = self._apply_resource(dry_run,
5✔
300
                                                             patch_field_excludes,
301
                                                             resource,
302
                                                             patch_func,
303
                                                             create_func,
304
                                                             delete_func,
305
                                                             status_msg)
306

307
            total_created += created
5✔
308
            total_patched += patched
5✔
309
            total_deleted += deleted
5✔
310

311
        if ((dump or dry_run) and
5✔
312
                k8s.field_validation_warn_fatal and self.context.globals.k8s.field_validation_warnings):
313
            msg = ("There were %d field validation warnings and the warnings are fatal!" %
5✔
314
                   self.context.globals.k8s.field_validation_warnings)
315
            logger.fatal(msg)
5✔
316
            raise RuntimeError(msg)
5✔
317

318
        if dump:
5✔
319
            if file_format in ("json", "json-pretty"):
5!
320
                json.dump(dump_results, file, sort_keys=True,
×
321
                          indent=4 if file_format == "json-pretty" else None)
322
            else:
323
                yaml.safe_dump(dump_results, file)
5✔
324
        else:
325
            self._summary = total_created, total_patched, total_deleted
5✔
326

327
    def handle_summary(self):
5✔
328
        total_created, total_patched, total_deleted = self._summary
5✔
329
        logger.info("Created %d, patched %d, deleted %d resources", total_created, total_patched, total_deleted)
5✔
330

331
    def api_load_resources(self, path: Path, file_type: str):
5✔
332
        return self.add_local_resources(path, FileType[file_type.upper()])
×
333

334
    def api_load_remote_resources(self, url: str, file_type: str, file_category=None):
5✔
335
        return self.add_remote_resources(url, FileType[file_type.upper()], sub_category=file_category)
×
336

337
    def api_load_crds(self, path: Path, file_type: str):
5✔
338
        return self.add_local_crds(path, FileType[file_type.upper()])
5✔
339

340
    def api_load_remote_crds(self, url: str, file_type: str, file_category=None):
5✔
341
        return self.add_remote_crds(url, FileType[file_type.upper()], sub_category=file_category)
5✔
342

343
    def api_add_transformer(self, transformer):
5✔
344
        if transformer not in self._transformers:
5!
345
            self._transformers.append(transformer)
5✔
346

347
    def api_add_validator(self, validator):
5✔
348
        if validator not in self._validators:
5!
349
            self._validators.append(validator)
5✔
350

351
    def api_remove_transformer(self, transformer):
5✔
352
        if transformer in self._transformers:
5!
353
            self._transformers.remove(transformer)
5✔
354

355
    def api_remove_validator(self, validator):
5✔
356
        if validator not in self._validators:
×
357
            self._validators.remove(validator)
×
358

359
    def api_validation_error(self, msg, *args):
5✔
360
        frame = sys._getframe().f_back
×
361
        tb = None
×
362
        while True:
363
            if not frame:
×
364
                break
×
365
            tb = types.TracebackType(tb, frame, frame.f_lasti, frame.f_lineno)
×
366
            frame = frame.f_back
×
367
        return ValueError((msg % args) if args else msg).with_traceback(tb)
×
368

369
    def _transform_resource(self, resources: Sequence[K8SResource], resource: K8SResource) -> K8SResource:
5✔
370
        for transformer in reversed(self._transformers):
5✔
371
            logger.debug("Applying transformer %s to %s from %s",
5✔
372
                         getattr(transformer, "__name__", transformer),
373
                         resource, resource.source)
374
            resource = transformer(resources, resource) or resource
5✔
375

376
        return resource
5✔
377

378
    def _validate_resources(self):
5✔
379
        errors: list[Exception] = []
5✔
380
        for resource in self.resources.values():
5✔
381
            for validator in reversed(self._validators):
5✔
382
                logger.debug("Applying validator %s to %s from %s",
5✔
383
                             getattr(validator, "__name__", validator),
384
                             resource, resource.source)
385
                errors.extend(validator(self.resources, resource, self.api_validation_error))
5✔
386
        if errors:
5!
387
            for error in errors:
×
388
                logger.error("Validation error: %s", error)
×
389
            raise errors[0]
×
390

391
    def _apply_resource(self,
5✔
392
                        dry_run,
393
                        patch_field_excludes: Iterable[re.compile],
394
                        resource: K8SResource,
395
                        patch_func: Callable[[Iterable[dict]], None],
396
                        create_func: Callable[[], None],
397
                        delete_func: Callable[[K8SPropagationPolicy], None],
398
                        status_msg):
399
        from kubernetes import client
5✔
400
        from kubernetes.client.rest import ApiException
5✔
401

402
        rdef = resource.rdef
5✔
403
        rdef.populate_api(client, self.context.k8s.client)
5✔
404

405
        def handle_400_strict_validation_error(e: ApiException):
5✔
406
            if e.status == 400:
5!
407
                status = json.loads(e.body)
5✔
408

409
                if status["status"] == "Failure":
5!
410
                    if FIELD_VALIDATION_STRICT_MARKER in status["message"]:
5!
411
                        message = status["message"]
5✔
412
                        messages = message[message.find(FIELD_VALIDATION_STRICT_MARKER) +
5✔
413
                                           len(FIELD_VALIDATION_STRICT_MARKER):].split(",")
414
                        for m in messages:
5✔
415
                            self._api_warnings(resource, m.strip())
5✔
416

417
                        raise e from None
5✔
418
                    else:
419
                        logger.error("FAILED MODIFYING resource %s from %s: %s",
×
420
                                     resource, resource.source, status["message"])
421
                        raise e from None
×
422

423
        def create(exists_ok=False):
5✔
424
            logger.info("Creating resource %s%s%s", resource, status_msg,
5✔
425
                        " (ignoring existing)" if exists_ok else "")
426
            try:
5✔
427
                create_func()
5✔
428
            except ApiException as e:
5✔
429
                if exists_ok:
5!
430
                    if e.status == 409:
×
431
                        status = json.loads(e.body)
×
432
                        if status["reason"] == "AlreadyExists":
×
433
                            return
×
434
                raise
5✔
435

436
        merge_instrs, normalized_manifest = extract_merge_instructions(resource.manifest, resource)
5✔
437
        if merge_instrs:
5✔
438
            logger.trace("Normalized manifest (no merge instructions) for resource %s: %s", resource,
5✔
439
                         normalized_manifest)
440
        else:
441
            normalized_manifest = resource.manifest
5✔
442

443
        logger.debug("Applying resource %s%s", resource, status_msg)
5✔
444
        try:
5✔
445
            remote_resource = resource.get()
5✔
446
            logger.trace("Current resource %s: %s", resource, remote_resource)
5✔
447
        except ApiException as e:
5✔
448
            if e.status == 404:
5!
449
                try:
5✔
450
                    create()
5✔
451
                    return 1, 0, 0
5✔
452
                except ApiException as e:
5✔
453
                    if not handle_400_strict_validation_error(e):
5✔
454
                        raise
1✔
455

456
            else:
457
                raise
1✔
458
        else:
459
            logger.trace("Attempting to retrieve a normalized patch for resource %s: %s", resource, normalized_manifest)
5✔
460
            try:
5✔
461
                merged_resource = resource.patch(normalized_manifest,
5✔
462
                                                 patch_type=K8SResourcePatchType.SERVER_SIDE_PATCH,
463
                                                 dry_run=True,
464
                                                 force=True)
465
            except ApiException as e:
×
466
                if e.status == 422:
×
467
                    status = json.loads(e.body)
×
468
                    details = status["details"]
×
469
                    immutable_key = details["group"], details["kind"]
×
470

471
                    try:
×
472
                        propagation_policy = self.context.k8s.immutable_changes[immutable_key]
×
473
                    except KeyError:
×
474
                        raise e from None
×
475
                    else:
476
                        for cause in details["causes"]:
×
477
                            if (
×
478
                                    cause["reason"] == "FieldValueInvalid" and
479
                                    "field is immutable" in cause["message"]
480
                                    or
481
                                    cause["reason"] == "FieldValueForbidden" and
482
                                    "Forbidden: updates to" in cause["message"]
483
                            ):
484
                                logger.info("Deleting resource %s (cascade %s)%s", resource,
×
485
                                            propagation_policy.policy,
486
                                            status_msg)
487
                                delete_func(propagation_policy=propagation_policy)
×
488
                                create(exists_ok=dry_run)
×
489
                                return 1, 0, 1
×
490
                        raise
×
491
                else:
492
                    if not handle_400_strict_validation_error(e):
×
493
                        raise
×
494
            else:
495
                logger.trace("Merged resource %s: %s", resource, merged_resource)
5✔
496
                if merge_instrs:
5✔
497
                    apply_merge_instructions(merge_instrs, normalized_manifest, merged_resource, logger, resource)
5✔
498

499
                patch = jsonpatch.make_patch(remote_resource, merged_resource)
5✔
500
                logger.trace("Resource %s initial patches are: %s", resource, patch)
5✔
501
                patch = self._filter_resource_patch(patch, patch_field_excludes)
5✔
502
                logger.trace("Resource %s final patches are: %s", resource, patch)
5✔
503
                if patch:
5✔
504
                    logger.info("Patching resource %s%s", resource, status_msg)
5✔
505
                    patch_func(patch)
5✔
506
                    return 0, 1, 0
5✔
507
                else:
508
                    logger.info("Nothing to patch for resource %s", resource)
5✔
509
                    return 0, 0, 0
5✔
510

511
    def _filter_resource_patch(self, patch: Iterable[Mapping], excludes: Iterable[re.compile]):
5✔
512
        result = []
5✔
513
        for op in patch:
5✔
514
            path = op["path"]
5✔
515
            excluded = False
5✔
516
            for exclude in excludes:
5✔
517
                if exclude.match(path):
5✔
518
                    logger.trace("Excluding %r from patch %s", op, patch)
5✔
519
                    excluded = True
5✔
520
                    break
5✔
521
            if excluded:
5✔
522
                continue
5✔
523
            result.append(op)
5✔
524
        return result
5✔
525

526
    def _setup_k8s_client(self):
5✔
527
        from kubernetes import client
5✔
528
        from kubernetes.config import load_incluster_config, load_kube_config, ConfigException
5✔
529

530
        try:
5✔
531
            logger.debug("Trying K8S in-cluster configuration")
5✔
532
            load_incluster_config()
5✔
533
            logger.info("Running K8S with in-cluster configuration")
×
534
        except ConfigException as e:
5✔
535
            logger.trace("K8S in-cluster configuration failed", exc_info=e)
5✔
536
            logger.debug("Initializing K8S with kubeconfig configuration")
5✔
537
            load_kube_config(config_file=self.context.kubeconfig.kubeconfig)
5✔
538

539
        k8s_client = client.ApiClient()
5✔
540

541
        # Patch the header content type selector to allow json patch
542
        k8s_client._select_header_content_type = k8s_client.select_header_content_type
5✔
543
        k8s_client.select_header_content_type = self._select_header_content_type_patch
5✔
544

545
        return k8s_client
5✔
546

547
    def _select_header_content_type_patch(self, content_types):
5✔
548
        """Returns `Content-Type` based on an array of content_types provided.
549
        :param content_types: List of content-types.
550
        :return: Content-Type (e.g. application/json).
551
        """
552

553
        content_type = self.context.k8s.client._select_header_content_type(content_types)
×
554
        if content_type == "application/merge-patch+json":
×
555
            return "application/json-patch+json"
×
556
        return content_type
×
557

558
    def __repr__(self):
5✔
559
        return "Kubernetes Plugin"
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