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

karellen / kubernator / 15092664197

18 May 2025 05:18AM UTC coverage: 76.274% (+1.8%) from 74.504%
15092664197

Pull #73

github

web-flow
Merge 2badb03ff into a877b6d38
Pull Request #73: Add support for Helm OCI-based charts

614 of 950 branches covered (64.63%)

Branch coverage included in aggregate %.

28 of 34 new or added lines in 2 files covered. (82.35%)

58 existing lines in 1 file now uncovered.

2379 of 2974 relevant lines covered (79.99%)

3.99 hits per line

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

74.65
/src/main/python/kubernator/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 fnmatch
5✔
20
import json
5✔
21
import logging
5✔
22
import os
5✔
23
import platform
5✔
24
import re
5✔
25
import sys
5✔
26
import traceback
5✔
27
import urllib.parse
5✔
28
from collections.abc import Callable
5✔
29
from collections.abc import Iterable, MutableSet, Reversible
5✔
30
from enum import Enum
5✔
31
from hashlib import sha256
5✔
32
from io import StringIO as io_StringIO
5✔
33
from pathlib import Path
5✔
34
from shutil import rmtree
5✔
35
from subprocess import CalledProcessError
5✔
36
from types import GeneratorType
5✔
37
from typing import Optional, Union, MutableSequence
5✔
38

39
import requests
5✔
40
import yaml
5✔
41
from diff_match_patch import diff_match_patch
5✔
42
from gevent import sleep
5✔
43
from jinja2 import (Environment,
5✔
44
                    ChainableUndefined,
45
                    make_logging_undefined,
46
                    Template as JinjaTemplate,
47
                    pass_context)
48
from jsonschema import validators
5✔
49
from platformdirs import user_cache_dir
5✔
50

51
from kubernator._json_path import jp  # noqa: F401
5✔
52
from kubernator._k8s_client_patches import (URLLIB_HEADERS_PATCH,
5✔
53
                                            CUSTOM_OBJECT_PATCH_23,
54
                                            CUSTOM_OBJECT_PATCH_25)
55

56
_CACHE_HEADER_TRANSLATION = {"etag": "if-none-match",
5✔
57
                             "last-modified": "if-modified-since"}
58
_CACHE_HEADERS = ("etag", "last-modified")
5✔
59

60

61
def calling_frame_source(depth=2):
5✔
62
    f = traceback.extract_stack(limit=depth + 1)[0]
×
63
    return f"file {f.filename}, line {f.lineno} in {f.name}"
×
64

65

66
def re_filter(name: str, patterns: Iterable[re.Pattern]):
5✔
67
    for pattern in patterns:
5✔
68
        if pattern.match(name):
5✔
69
            return True
5✔
70

71

72
def to_patterns(*patterns):
5✔
73
    return [re.compile(fnmatch.translate(p)) for p in patterns]
×
74

75

76
def scan_dir(logger, path: Path, path_filter: Callable[[os.DirEntry], bool], excludes, includes):
5✔
77
    logger.debug("Scanning %s, excluding %s, including %s", path, excludes, includes)
5✔
78
    with os.scandir(path) as it:  # type: Iterable[os.DirEntry]
5✔
79
        files = {f: f for f in
5✔
80
                 sorted(d.name for d in it if path_filter(d) and not re_filter(d.name, excludes))}
81

82
    for include in includes:
5✔
83
        logger.trace("Considering include %s in %s", include, path)
5✔
84
        for f in list(files.keys()):
5✔
85
            if include.match(f):
5✔
86
                del files[f]
5✔
87
                logger.debug("Selecting %s in %s as it matches %s", f, path, include)
5✔
88
                yield path / f
5✔
89

90

91
class FileType(Enum):
5✔
92
    JSON = (json.load,)
5✔
93
    YAML = (yaml.safe_load_all,)
5✔
94

95
    def __init__(self, func):
5✔
96
        self.func = func
5✔
97

98

99
def _load_file(logger, path: Path, file_type: FileType, source=None) -> Iterable[dict]:
5✔
100
    with open(path, "rb") as f:
5✔
101
        try:
5✔
102
            data = file_type.func(f)
5✔
103
            if isinstance(data, GeneratorType):
5✔
104
                data = list(data)
5✔
105
            return data
5✔
106
        except Exception as e:
×
107
            logger.error("Failed parsing %s using %s", source or path, file_type, exc_info=e)
×
108
            raise
×
109

110

111
def _download_remote_file(url, file_name, cache: dict):
5✔
112
    retry_delay = 0
5✔
113
    while True:
4✔
114
        if retry_delay:
5✔
115
            sleep(retry_delay)
2✔
116

117
        with requests.get(url, headers=cache, stream=True) as r:
5✔
118
            if r.status_code == 429:
5✔
119
                print(r.headers)
2✔
120
                if not retry_delay:
2!
121
                    retry_delay = 0.5
2✔
122
                else:
NEW
123
                    retry_delay *= 2.0
×
124
                if retry_delay > 2.5:
2!
NEW
125
                    retry_delay = 2.5
×
126
                continue
2✔
127

128
            r.raise_for_status()
5✔
129
            if r.status_code != 304:
5!
NEW
130
                with open(file_name, "wb") as out:
×
NEW
131
                    for chunk in r.iter_content(chunk_size=65535):
×
NEW
132
                        out.write(chunk)
×
NEW
133
                return dict(r.headers)
×
134
            else:
135
                return None
5✔
136

137

138
def get_app_cache_dir():
5✔
139
    return Path(user_cache_dir("kubernator", "karellen"))
5✔
140

141

142
def get_cache_dir(category: str, sub_category: str = None):
5✔
143
    cache_dir = get_app_cache_dir() / category
5✔
144
    if sub_category:
5!
UNCOV
145
        cache_dir = cache_dir / sub_category
×
146
    if not cache_dir.exists():
5✔
147
        cache_dir.mkdir(parents=True)
5✔
148

149
    return cache_dir
5✔
150

151

152
def download_remote_file(logger, url: str, category: str = "k8s", sub_category: str = None,
5✔
153
                         downloader=_download_remote_file):
154
    config_dir = get_cache_dir(category, sub_category)
5✔
155

156
    file_name = config_dir / sha256(url.encode("UTF-8")).hexdigest()
5✔
157
    cache_file_name = file_name.with_suffix(".cache")
5✔
158
    logger.trace("Cache file for %s is %s.cache", url, file_name)
5✔
159

160
    cache = {}
5✔
161
    if cache_file_name.exists():
5!
162
        logger.trace("Loading cache file from %s", cache_file_name)
5✔
163
        try:
5✔
164
            with open(cache_file_name, "rb") as cache_f:
5✔
165
                cache = json.load(cache_f)
5✔
166
        except (IOError, ValueError) as e:
×
167
            logger.trace("Failed loading cache file from %s (cleaning up)", cache_file_name, exc_info=e)
×
UNCOV
168
            cache_file_name.unlink(missing_ok=True)
×
169

170
    logger.trace("Downloading %s into %s%s", url, file_name, " (caching)" if cache else "")
5✔
171
    headers = downloader(url, file_name, cache)
5✔
172
    up_to_date = False
5✔
173
    if not headers:
5!
174
        logger.trace("File %s(%s) is up-to-date", url, file_name.name)
5✔
175
        up_to_date = True
5✔
176
    else:
UNCOV
177
        cache = {_CACHE_HEADER_TRANSLATION.get(k.lower(), k): v
×
178
                 for k, v in headers.items()
179
                 if k.lower() in _CACHE_HEADERS}
180

181
        logger.trace("Update cache file in %s: %r", cache_file_name, cache)
×
182
        with open(cache_file_name, "wt") as cache_f:
×
UNCOV
183
            json.dump(cache, cache_f)
×
184

185
    return file_name, up_to_date
5✔
186

187

188
def load_remote_file(logger, url, file_type: FileType, category: str = "k8s", sub_category: str = None,
5✔
189
                     downloader=_download_remote_file):
190
    file_name, _ = download_remote_file(logger, url, category, sub_category, downloader=downloader)
5✔
191
    logger.debug("Loading %s from %s using %s", url, file_name, file_type.name)
5✔
192
    return _load_file(logger, file_name, file_type, url)
5✔
193

194

195
def load_file(logger, path: Path, file_type: FileType, source=None) -> Iterable[dict]:
5✔
196
    logger.debug("Loading %s using %s", source or path, file_type.name)
5✔
197
    return _load_file(logger, path, file_type)
5✔
198

199

200
def validator_with_defaults(validator_class):
5✔
201
    validate_properties = validator_class.VALIDATORS["properties"]
5✔
202

203
    def set_defaults(validator, properties, instance, schema):
5✔
204
        for property, subschema in properties.items():
5✔
205
            if "default" in subschema:
5✔
206
                instance.setdefault(property, subschema["default"])
5✔
207

208
        for error in validate_properties(validator, properties, instance, schema):
5!
UNCOV
209
            yield error
×
210

211
    return validators.extend(validator_class, {"properties": set_defaults})
5✔
212

213

214
class _PropertyList(MutableSequence):
5✔
215

216
    def __init__(self, seq, read_parent, name):
5✔
217
        self.__read_seq = seq
5✔
218
        self.__read_parent = read_parent
5✔
219
        self.__write_parent = None
5✔
220
        self.__write_seq = None
5✔
221
        self.__name = name
5✔
222

223
    def __iter__(self):
5✔
224
        return self.__read_seq.__iter__()
5✔
225

226
    def __mul__(self, __n):
5✔
UNCOV
227
        return self.__read_seq.__mul__(__n)
×
228

229
    def __rmul__(self, __n):
5✔
UNCOV
230
        return self.__read_seq.__rmul__(__n)
×
231

232
    def __imul__(self, __n):
5✔
UNCOV
233
        return self.__read_seq.__imul__(__n)
×
234

235
    def __contains__(self, __o):
5✔
UNCOV
236
        return self.__read_seq.__contains__(__o)
×
237

238
    def __reversed__(self):
5✔
239
        return self.__read_seq.__reversed__()
5✔
240

241
    def __gt__(self, __x):
5✔
UNCOV
242
        return self.__read_seq.__gt__(__x)
×
243

244
    def __ge__(self, __x):
5✔
UNCOV
245
        return self.__read_seq.__ge__(__x)
×
246

247
    def __lt__(self, __x):
5✔
UNCOV
248
        return self.__read_seq.__lt__(__x)
×
249

250
    def __le__(self, __x):
5✔
UNCOV
251
        return self.__read_seq.__le__(__x)
×
252

253
    def __len__(self):
5✔
254
        return self.__read_seq.__len__()
5✔
255

256
    def count(self, __value):
5✔
UNCOV
257
        return self.__read_seq.count(__value)
×
258

259
    def copy(self):
5✔
260
        while True:
261
            try:
×
262
                return self.__write_seq.copy()
×
263
            except AttributeError:
×
UNCOV
264
                self.__clone()
×
265

266
    def __getitem__(self, __i):
5✔
267
        return self.__read_seq.__getitem__(__i)
5✔
268

269
    def append(self, __object):
5✔
270
        while True:
4✔
271
            try:
5✔
272
                return self.__write_seq.append(__object)
5✔
273
            except AttributeError:
5✔
274
                self.__clone()
5✔
275

276
    def extend(self, __iterable):
5✔
277
        while True:
278
            try:
×
279
                return self.__write_seq.extend(__iterable)
×
280
            except AttributeError:
×
UNCOV
281
                self.__clone()
×
282

283
    def pop(self, __index=None):
5✔
284
        while True:
285
            try:
×
286
                return self.__write_seq.pop(__index)
×
287
            except AttributeError:
×
UNCOV
288
                self.__clone()
×
289

290
    def insert(self, __index, __object):
5✔
291
        while True:
292
            try:
×
293
                return self.__write_seq.insert(__index, __object)
×
294
            except AttributeError:
×
UNCOV
295
                self.__clone()
×
296

297
    def remove(self, __value):
5✔
298
        while True:
299
            try:
×
300
                return self.__write_seq.remove(__value)
×
301
            except AttributeError:
×
UNCOV
302
                self.__clone()
×
303

304
    def sort(self, *, key=None, reverse=False):
5✔
305
        while True:
306
            try:
×
307
                return self.__write_seq.sort(key=key, reverse=reverse)
×
308
            except AttributeError:
×
UNCOV
309
                self.__clone()
×
310

311
    def __setitem__(self, __i, __o):
5✔
312
        while True:
313
            try:
×
314
                return self.__write_seq.__setitem__(__i, __o)
×
315
            except AttributeError:
×
UNCOV
316
                self.__clone()
×
317

318
    def __delitem__(self, __i):
5✔
319
        while True:
320
            try:
×
321
                return self.__write_seq.__delitem__(__i)
×
322
            except AttributeError:
×
UNCOV
323
                self.__clone()
×
324

325
    def __add__(self, __x):
5✔
326
        while True:
327
            try:
×
328
                return self.__write_seq.__add__(__x)
×
329
            except AttributeError:
×
UNCOV
330
                self.__clone()
×
331

332
    def __iadd__(self, __x):
5✔
333
        while True:
334
            try:
×
335
                return self.__write_seq.__iadd__(__x)
×
336
            except AttributeError:
×
UNCOV
337
                self.__clone()
×
338

339
    def clear(self):
5✔
340
        while True:
341
            try:
×
342
                return self.__write_seq.clear()
×
343
            except AttributeError:
×
UNCOV
344
                self.__clone()
×
345

346
    def reverse(self):
5✔
347
        while True:
348
            try:
×
349
                return self.__write_seq.reverse()
×
350
            except AttributeError:
×
UNCOV
351
                self.__clone()
×
352

353
    def __clone(self):
5✔
354
        if self.__read_parent == self.__write_parent:
5✔
355
            self.__write_seq = self.__read_seq
5✔
356
        else:
357
            self.__write_seq = self.__read_seq.copy()
5✔
358
            self.__read_seq = self.__write_seq
5✔
359

360
            setattr(self.__write_parent, self.__name, self.__write_seq)
5✔
361

362

363
class PropertyDict:
5✔
364
    def __init__(self, _dict=None, _parent=None):
5✔
365
        self.__dict__["_PropertyDict__dict"] = _dict or {}
5✔
366
        self.__dict__["_PropertyDict__parent"] = _parent
5✔
367

368
    def __getattr__(self, item):
5✔
369
        v = self.__getattr(item)
5✔
370
        if isinstance(v, _PropertyList):
5✔
371
            v._PropertyList__write_parent = self
5✔
372
        return v
5✔
373

374
    def __getattr(self, item):
5✔
375
        try:
5✔
376
            v = self.__dict[item]
5✔
377
            if isinstance(v, list):
5✔
378
                v = _PropertyList(v, self, item)
5✔
379
            return v
5✔
380
        except KeyError:
5✔
381
            parent = self.__parent
5✔
382
            if parent is not None:
5!
383
                return parent.__getattr(item)
5✔
UNCOV
384
            raise AttributeError("no attribute %r" % item) from None
×
385

386
    def __setattr__(self, key, value):
5✔
387
        if key.startswith("_PropertyDict__"):
5!
UNCOV
388
            raise AttributeError("prohibited attribute %r" % key)
×
389
        if isinstance(value, dict):
5✔
390
            parent_dict = None
5✔
391
            if self.__parent is not None:
5✔
392
                try:
5✔
393
                    parent_dict = self.__parent.__getattr__(key)
5✔
394
                    if not isinstance(parent_dict, PropertyDict):
5!
395
                        raise ValueError("cannot override a scalar with a synthetic object for attribute %s", key)
×
396
                except AttributeError:
×
UNCOV
397
                    pass
×
398
            value = PropertyDict(value, _parent=parent_dict)
5✔
399
        self.__dict[key] = value
5✔
400

401
    def __delattr__(self, item):
5✔
402
        del self.__dict[item]
5✔
403

404
    def __len__(self):
5✔
UNCOV
405
        return len(self.__dir__())
×
406

407
    def __getitem__(self, item):
5✔
UNCOV
408
        return self.__dict.__getitem__(item)
×
409

410
    def __setitem__(self, key, value):
5✔
UNCOV
411
        self.__dict.__setitem__(key, value)
×
412

413
    def __delitem__(self, key):
5✔
UNCOV
414
        self.__dict.__delitem__(key)
×
415

416
    def __contains__(self, item):
5✔
417
        try:
5✔
418
            self.__dict[item]
5✔
419
            return True
5✔
420
        except KeyError:
5✔
421
            parent = self.__parent
5✔
422
            if parent is not None:
5✔
423
                return parent.__contains__(item)
5✔
424
            return False
5✔
425

426
    def __dir__(self) -> Iterable[str]:
5✔
427
        result: set[str] = set()
×
428
        result.update(self.__dict.keys())
×
429
        if self.__parent is not None:
×
430
            result.update(self.__parent.__dir__())
×
UNCOV
431
        return result
×
432

433
    def __repr__(self):
5✔
UNCOV
434
        return "PropertyDict[%r]" % self.__dict
×
435

436

437
def config_parent(config: PropertyDict):
5✔
UNCOV
438
    return config._PropertyDict__parent
×
439

440

441
def config_as_dict(config: PropertyDict):
5✔
UNCOV
442
    return {k: config[k] for k in dir(config)}
×
443

444

445
def config_get(config: PropertyDict, key: str, default=None):
5✔
446
    try:
×
447
        return config[key]
×
448
    except KeyError:
×
UNCOV
449
        return default
×
450

451

452
class Globs(MutableSet[Union[str, re.Pattern]]):
5✔
453
    def __init__(self, source: Optional[list[Union[str, re.Pattern]]] = None,
5✔
454
                 immutable=False):
455
        self._immutable = immutable
5✔
456
        if source:
5!
457
            self._list = [self.__wrap__(v) for v in source]
5✔
458
        else:
UNCOV
459
            self._list = []
×
460

461
    def __wrap__(self, item: Union[str, re.Pattern]):
5✔
462
        if isinstance(item, re.Pattern):
5✔
463
            return item
5✔
464
        return re.compile(fnmatch.translate(item))
5✔
465

466
    def __contains__(self, item: Union[str, re.Pattern]):
5✔
UNCOV
467
        return self._list.__contains__(self.__wrap__(item))
×
468

469
    def __iter__(self):
5✔
470
        return self._list.__iter__()
5✔
471

472
    def __len__(self):
5✔
473
        return self._list.__len__()
5✔
474

475
    def add(self, value: Union[str, re.Pattern]):
5✔
476
        if self._immutable:
5!
UNCOV
477
            raise RuntimeError("immutable")
×
478

479
        _list = self._list
5✔
480
        value = self.__wrap__(value)
5✔
481
        if value not in _list:
5!
482
            _list.append(value)
5✔
483

484
    def extend(self, values: Iterable[Union[str, re.Pattern]]):
5✔
485
        for v in values:
×
UNCOV
486
            self.add(v)
×
487

488
    def discard(self, value: Union[str, re.Pattern]):
5✔
489
        if self._immutable:
5!
UNCOV
490
            raise RuntimeError("immutable")
×
491

492
        _list = self._list
5✔
493
        value = self.__wrap__(value)
5✔
494
        if value in _list:
5!
495
            _list.remove(value)
5✔
496

497
    def add_first(self, value: Union[str, re.Pattern]):
5✔
498
        if self._immutable:
×
UNCOV
499
            raise RuntimeError("immutable")
×
500

501
        _list = self._list
×
502
        value = self.__wrap__(value)
×
503
        if value not in _list:
×
UNCOV
504
            _list.insert(0, value)
×
505

506
    def extend_first(self, values: Reversible[Union[str, re.Pattern]]):
5✔
507
        for v in reversed(values):
×
UNCOV
508
            self.add_first(v)
×
509

510
    def __str__(self):
5✔
511
        return self._list.__str__()
5✔
512

513
    def __repr__(self):
5✔
UNCOV
514
        return f"Globs[{self._list}]"
×
515

516

517
class TemplateEngine:
5✔
518
    VARIABLE_START_STRING = "{${"
5✔
519
    VARIABLE_END_STRING = "}$}"
5✔
520

521
    def __init__(self, logger):
5✔
522
        self.template_failures = 0
5✔
523
        self.templates = {}
5✔
524

525
        class CollectingUndefined(ChainableUndefined):
5✔
526
            __slots__ = ()
5✔
527

528
            def __str__(self):
5✔
529
                self.template_failures += 1
×
UNCOV
530
                return super().__str__()
×
531

532
        logging_undefined = make_logging_undefined(
5✔
533
            logger=logger,
534
            base=CollectingUndefined
535
        )
536

537
        @pass_context
5✔
538
        def variable_finalizer(ctx, value):
5✔
539
            normalized_value = str(value)
5✔
540
            if self.VARIABLE_START_STRING in normalized_value and self.VARIABLE_END_STRING in normalized_value:
5✔
541
                value_template_content = sys.intern(normalized_value)
5✔
542
                env: Environment = ctx.environment
5✔
543
                value_template = self.templates.get(value_template_content)
5✔
544
                if not value_template:
5!
545
                    value_template = env.from_string(value_template_content, env.globals)
5✔
546
                    self.templates[value_template_content] = value_template
5✔
547
                return value_template.render(ctx.parent)
5✔
548

549
            return normalized_value
5✔
550

551
        self.env = Environment(variable_start_string=self.VARIABLE_START_STRING,
5✔
552
                               variable_end_string=self.VARIABLE_END_STRING,
553
                               autoescape=False,
554
                               finalize=variable_finalizer,
555
                               undefined=logging_undefined)
556

557
    def from_string(self, template):
5✔
558
        return self.env.from_string(template)
5✔
559

560
    def failures(self):
5✔
561
        return self.template_failures
5✔
562

563

564
class Template:
5✔
565
    def __init__(self, name: str, template: JinjaTemplate, defaults: dict = None, path=None, source=None):
5✔
566
        self.name = name
5✔
567
        self.source = source
5✔
568
        self.path = path
5✔
569
        self.template = template
5✔
570
        self.defaults = defaults
5✔
571

572
    def render(self, context: dict, values: dict):
5✔
573
        variables = {"ktor": context,
5✔
574
                     "values": (self.defaults or {}) | values}
575
        return self.template.render(variables)
5✔
576

577

578
class StringIO:
5✔
579
    def __init__(self, trimmed=True):
5✔
580
        self.write = self.write_trimmed if trimmed else self.write_untrimmed
5✔
581
        self._buf = io_StringIO()
5✔
582

583
    def write_untrimmed(self, line):
5✔
584
        self._buf.write(line)
5✔
585

586
    def write_trimmed(self, line):
5✔
UNCOV
587
        self._buf.write(f"{line}\n")
×
588

589
    def getvalue(self):
5✔
590
        return self._buf.getvalue()
5✔
591

592

593
class StripNL:
5✔
594
    def __init__(self, func):
5✔
595
        self._func = func
5✔
596

597
    def __call__(self, line: str):
5✔
598
        return self._func(line.rstrip("\r\n"))
5✔
599

600

601
def log_level_to_verbosity_count(level: int):
5✔
602
    return int(-level / 10 + 6)
5✔
603

604

605
def clone_url_str(url):
5✔
606
    return urllib.parse.urlunsplit(url[:3] + ("", ""))  # no query or fragment
5✔
607

608

609
def prepend_os_path(path):
5✔
610
    path = str(path)
5✔
611
    paths = os.environ["PATH"].split(os.pathsep)
5✔
612
    if path not in paths:
5!
613
        paths.insert(0, path)
5✔
614
        os.environ["PATH"] = os.pathsep.join(paths)
5✔
615
        return True
5✔
UNCOV
616
    return False
×
617

618

619
_GOLANG_MACHINE = platform.machine().lower()
5✔
620
if _GOLANG_MACHINE == "x86_64":
5!
621
    _GOLANG_MACHINE = "amd64"
5✔
622

623
_GOLANG_OS = platform.system().lower()
5✔
624

625

626
def get_golang_machine():
5✔
627
    return _GOLANG_MACHINE
5✔
628

629

630
def get_golang_os():
5✔
631
    return _GOLANG_OS
5✔
632

633

634
def sha256_file_digest(path):
5✔
635
    h = sha256()
×
636
    with open(path, "rb") as f:
×
637
        h.update(f.read(65535))
×
UNCOV
638
    return h.hexdigest()
×
639

640

641
class Repository:
5✔
642
    logger = logging.getLogger("kubernator.repository")
5✔
643
    git_logger = logger.getChild("git")
5✔
644

645
    def __init__(self, repo, cred_aug=None):
5✔
646
        repo = str(repo)  # in case this is a Path
5✔
647
        url = urllib.parse.urlsplit(repo)
5✔
648

649
        if not url.scheme and not url.netloc and Path(url.path).exists():
5!
650
            url = url._replace(scheme="file")  # In case it's a local repository
5✔
651

652
        self.url = url
5✔
653
        self.url_str = urllib.parse.urlunsplit(url[:4] + ("",))
5✔
654
        self._cred_aug = cred_aug
5✔
655
        self._hash_obj = (url.hostname if url.username or url.password else url.netloc,
5✔
656
                          url.path,
657
                          url.query)
658

659
        self.clone_url = None  # Actual URL components used in cloning operations
5✔
660
        self.clone_url_str = None  # Actual URL string used in cloning operations
5✔
661
        self.ref = None
5✔
662
        self.local_dir = None
5✔
663

664
    def __eq__(self, o: object) -> bool:
5✔
665
        if isinstance(o, Repository):
×
UNCOV
666
            return self._hash_obj == o._hash_obj
×
667

668
    def __hash__(self) -> int:
5✔
669
        return hash(self._hash_obj)
5✔
670

671
    def init(self, logger, context):
5✔
672
        run = context.app.run
5✔
673
        run_capturing_out = context.app.run_capturing_out
5✔
674

675
        url = self.url
5✔
676
        if self._cred_aug:
5!
677
            url = self._cred_aug(url)
5✔
678

679
        self.clone_url = url
5✔
680
        self.clone_url_str = clone_url_str(url)
5✔
681

682
        query = urllib.parse.parse_qs(self.url.query)
5✔
683
        ref = query.get("ref")
5✔
684
        if ref:
5✔
685
            self.ref = ref[0]
5✔
686

687
        config_dir = get_cache_dir("git")
5✔
688

689
        git_cache = config_dir / sha256(self.clone_url_str.encode("UTF-8")).hexdigest()
5✔
690

691
        if git_cache.exists() and git_cache.is_dir() and (git_cache / ".git").exists():
5✔
692
            try:
5✔
693
                run(["git", "status"], None, None, cwd=git_cache).wait()
5✔
694
            except CalledProcessError:
×
UNCOV
695
                rmtree(git_cache)
×
696

697
        self.local_dir = git_cache
5✔
698

699
        stdout_logger = StripNL(self.git_logger.debug)
5✔
700
        stderr_logger = StripNL(self.git_logger.info)
5✔
701
        if git_cache.exists():
5✔
702
            if not self.ref:
5!
UNCOV
703
                ref = run_capturing_out(["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
×
704
                                        stderr_logger, cwd=git_cache).strip()[7:]  # Remove prefix "origin/"
705
            else:
706
                ref = self.ref
5✔
707
            self.logger.info("Using %s%s cached in %s", self.url_str,
5✔
708
                             f"?ref={ref}" if not self.ref else "",
709
                             self.local_dir)
710
            run(["git", "config", "remote.origin.fetch", f"+refs/heads/{ref}:refs/remotes/origin/{ref}"],
5✔
711
                stdout_logger, stderr_logger, cwd=git_cache).wait()
712
            run(["git", "fetch", "-pPt", "--force"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
713
            run(["git", "checkout", ref], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
714
            run(["git", "clean", "-f"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
715
            run(["git", "reset", "--hard", ref, "--"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
716
            run(["git", "pull"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
717
        else:
718
            self.logger.info("Initializing %s -> %s", self.url_str, self.local_dir)
5✔
719
            args = (["git", "clone", "--depth", "1",
5✔
720
                     "-" + ("v" * log_level_to_verbosity_count(logger.getEffectiveLevel()))] +
721
                    (["-b", self.ref] if self.ref else []) +
722
                    ["--", self.clone_url_str, str(self.local_dir)])
723
            safe_args = [c if c != self.clone_url_str else self.url_str for c in args]
5✔
724
            run(args, stdout_logger, stderr_logger, safe_args=safe_args).wait()
5✔
725

726
    def cleanup(self):
5✔
727
        if False and self.local_dir:
5!
728
            self.logger.info("Cleaning up %s -> %s", self.url_str, self.local_dir)
729
            rmtree(self.local_dir)
730

731

732
class KubernatorPlugin:
5✔
733
    _name = None
5✔
734

735
    def set_context(self, context):
5✔
UNCOV
736
        raise NotImplementedError
×
737

738
    def register(self, **kwargs):
5✔
739
        pass
5✔
740

741
    def handle_init(self):
5✔
742
        pass
5✔
743

744
    def handle_start(self):
5✔
745
        pass
5✔
746

747
    def handle_before_dir(self, cwd: Path):
5✔
748
        pass
5✔
749

750
    def handle_before_script(self, cwd: Path):
5✔
751
        pass
5✔
752

753
    def handle_after_script(self, cwd: Path):
5✔
754
        pass
5✔
755

756
    def handle_after_dir(self, cwd: Path):
5✔
757
        pass
5✔
758

759
    def handle_apply(self):
5✔
760
        pass
5✔
761

762
    def handle_verify(self):
5✔
763
        pass
5✔
764

765
    def handle_shutdown(self):
5✔
766
        pass
5✔
767

768
    def handle_summary(self):
5✔
769
        pass
5✔
770

771

772
def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_stderr, disable_patching):
5✔
773
    cache_dir = get_cache_dir("python")
5✔
774
    package_major_dir = cache_dir / str(package_major)
5✔
775
    package_major_dir_str = str(package_major_dir)
5✔
776
    patch_indicator = package_major_dir / ".patched"
5✔
777

778
    if disable_patching and package_major_dir.exists() and patch_indicator.exists():
5✔
779
        logger.info("Patching is disabled, existing Kubernetes Client %s (%s) was patched - "
5✔
780
                    "deleting current client",
781
                    str(package_major), package_major_dir)
782
        rmtree(package_major_dir)
5✔
783

784
    if not package_major_dir.exists():
5✔
785
        package_major_dir.mkdir(parents=True, exist_ok=True)
5✔
786
        run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input",
5✔
787
             "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
788
             "--target", package_major_dir_str, f"kubernetes>={package_major!s}dev0,<{int(package_major) + 1!s}"],
789
            logger_stdout, logger_stderr).wait()
790

791
    if not patch_indicator.exists() and not disable_patching:
5✔
792
        for patch_text, target_file, skip_if_found, min_version, max_version, name in (
5✔
793
                URLLIB_HEADERS_PATCH, CUSTOM_OBJECT_PATCH_23, CUSTOM_OBJECT_PATCH_25):
794
            patch_target = package_major_dir / target_file
5✔
795
            logger.info("Applying patch %s to %s...", name, patch_target)
5✔
796
            if min_version and int(package_major) < min_version:
5✔
797
                logger.info("Skipping patch %s on %s due to package major version %s below minimum %d!",
5✔
798
                            name, patch_target, package_major, min_version)
799
                continue
5✔
800
            if max_version and int(package_major) > max_version:
5✔
801
                logger.info("Skipping patch %s on %s due to package major version %s above maximum %d!",
5✔
802
                            name, patch_target, package_major, max_version)
803
                continue
5✔
804

805
            with open(patch_target, "rt") as f:
5✔
806
                target_file_original = f.read()
5✔
807
            if skip_if_found in target_file_original:
5✔
808
                logger.info("Skipping patch %s on %s, as it already appears to be patched!", name,
5✔
809
                            patch_target)
810
                continue
5✔
811

812
            dmp = diff_match_patch()
5✔
813
            patches = dmp.patch_fromText(patch_text)
5✔
814
            target_file_patched, results = dmp.patch_apply(patches, target_file_original)
5✔
815
            failed_patch = False
5✔
816
            for idx, result in enumerate(results):
5✔
817
                if not result:
5!
818
                    failed_patch = True
×
UNCOV
819
                    msg = ("Failed to apply a patch to Kubernetes Client API %s, hunk #%d, patch: \n%s" % (
×
820
                        patch_target, idx, patches[idx]))
UNCOV
821
                    logger.fatal(msg)
×
822
            if failed_patch:
5!
UNCOV
823
                raise RuntimeError(f"Failed to apply some Kubernetes Client API {patch_target} patches")
×
824

825
            with open(patch_target, "wt") as f:
5✔
826
                f.write(target_file_patched)
5✔
827

828
        patch_indicator.touch(exist_ok=False)
5✔
829

830
    return package_major_dir
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