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

karellen / kubernator / 15090633286

18 May 2025 12:41AM UTC coverage: 75.746% (+1.2%) from 74.504%
15090633286

Pull #73

github

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

580 of 915 branches covered (63.39%)

Branch coverage included in aggregate %.

24 of 29 new or added lines in 2 files covered. (82.76%)

90 existing lines in 1 file now uncovered.

2365 of 2973 relevant lines covered (79.55%)

1.59 hits per line

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

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

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

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

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

59

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

64

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

70

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

74

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

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

89

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

94
    def __init__(self, func):
2✔
95
        self.func = func
2✔
96

97

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

109

110
def _download_remote_file(url, file_name, cache: dict):
2✔
111
    with requests.get(url, headers=cache, stream=True) as r:
2✔
112
        r.raise_for_status()
2✔
113
        if r.status_code != 304:
2!
NEW
114
            with open(file_name, "wb") as out:
×
NEW
115
                for chunk in r.iter_content(chunk_size=65535):
×
NEW
116
                    out.write(chunk)
×
NEW
117
            return dict(r.headers)
×
118

119

120
def get_app_cache_dir():
2✔
121
    return Path(user_cache_dir("kubernator", "karellen"))
2✔
122

123

124
def get_cache_dir(category: str, sub_category: str = None):
2✔
125
    cache_dir = get_app_cache_dir() / category
2✔
126
    if sub_category:
2!
NEW
127
        cache_dir = cache_dir / sub_category
×
128
    if not cache_dir.exists():
2✔
129
        cache_dir.mkdir(parents=True)
2✔
130

131
    return cache_dir
2✔
132

133

134
def download_remote_file(logger, url: str, category: str = "k8s", sub_category: str = None,
2✔
135
                         downloader=_download_remote_file):
136
    config_dir = get_cache_dir(category, sub_category)
2✔
137

138
    file_name = config_dir / sha256(url.encode("UTF-8")).hexdigest()
2✔
139
    cache_file_name = file_name.with_suffix(".cache")
2✔
140
    logger.trace("Cache file for %s is %s.cache", url, file_name)
2✔
141

142
    cache = {}
2✔
143
    if cache_file_name.exists():
2!
144
        logger.trace("Loading cache file from %s", cache_file_name)
2✔
145
        try:
2✔
146
            with open(cache_file_name, "rb") as cache_f:
2✔
147
                cache = json.load(cache_f)
2✔
UNCOV
148
        except (IOError, ValueError) as e:
×
UNCOV
149
            logger.trace("Failed loading cache file from %s (cleaning up)", cache_file_name, exc_info=e)
×
UNCOV
150
            cache_file_name.unlink(missing_ok=True)
×
151

152
    logger.trace("Downloading %s into %s%s", url, file_name, " (caching)" if cache else "")
2✔
153
    headers = downloader(url, file_name, cache)
2✔
154
    up_to_date = False
2✔
155
    if not headers:
2!
156
        logger.trace("File %s(%s) is up-to-date", url, file_name.name)
2✔
157
        up_to_date = True
2✔
158
    else:
UNCOV
159
        cache = {_CACHE_HEADER_TRANSLATION.get(k.lower(), k): v
×
160
                 for k, v in headers.items()
161
                 if k.lower() in _CACHE_HEADERS}
162

UNCOV
163
        logger.trace("Update cache file in %s: %r", cache_file_name, cache)
×
164
        with open(cache_file_name, "wt") as cache_f:
×
165
            json.dump(cache, cache_f)
×
166

167
    return file_name, up_to_date
2✔
168

169

170
def load_remote_file(logger, url, file_type: FileType, category: str = "k8s", sub_category: str = None,
2✔
171
                     downloader=_download_remote_file):
172
    file_name, _ = download_remote_file(logger, url, category, sub_category, downloader=downloader)
2✔
173
    logger.debug("Loading %s from %s using %s", url, file_name, file_type.name)
2✔
174
    return _load_file(logger, file_name, file_type, url)
2✔
175

176

177
def load_file(logger, path: Path, file_type: FileType, source=None) -> Iterable[dict]:
2✔
178
    logger.debug("Loading %s using %s", source or path, file_type.name)
2✔
179
    return _load_file(logger, path, file_type)
2✔
180

181

182
def validator_with_defaults(validator_class):
2✔
183
    validate_properties = validator_class.VALIDATORS["properties"]
2✔
184

185
    def set_defaults(validator, properties, instance, schema):
2✔
186
        for property, subschema in properties.items():
2✔
187
            if "default" in subschema:
2✔
188
                instance.setdefault(property, subschema["default"])
2✔
189

190
        for error in validate_properties(validator, properties, instance, schema):
2!
UNCOV
191
            yield error
×
192

193
    return validators.extend(validator_class, {"properties": set_defaults})
2✔
194

195

196
class _PropertyList(MutableSequence):
2✔
197

198
    def __init__(self, seq, read_parent, name):
2✔
199
        self.__read_seq = seq
2✔
200
        self.__read_parent = read_parent
2✔
201
        self.__write_parent = None
2✔
202
        self.__write_seq = None
2✔
203
        self.__name = name
2✔
204

205
    def __iter__(self):
2✔
206
        return self.__read_seq.__iter__()
2✔
207

208
    def __mul__(self, __n):
2✔
UNCOV
209
        return self.__read_seq.__mul__(__n)
×
210

211
    def __rmul__(self, __n):
2✔
UNCOV
212
        return self.__read_seq.__rmul__(__n)
×
213

214
    def __imul__(self, __n):
2✔
UNCOV
215
        return self.__read_seq.__imul__(__n)
×
216

217
    def __contains__(self, __o):
2✔
UNCOV
218
        return self.__read_seq.__contains__(__o)
×
219

220
    def __reversed__(self):
2✔
221
        return self.__read_seq.__reversed__()
2✔
222

223
    def __gt__(self, __x):
2✔
UNCOV
224
        return self.__read_seq.__gt__(__x)
×
225

226
    def __ge__(self, __x):
2✔
UNCOV
227
        return self.__read_seq.__ge__(__x)
×
228

229
    def __lt__(self, __x):
2✔
UNCOV
230
        return self.__read_seq.__lt__(__x)
×
231

232
    def __le__(self, __x):
2✔
UNCOV
233
        return self.__read_seq.__le__(__x)
×
234

235
    def __len__(self):
2✔
236
        return self.__read_seq.__len__()
2✔
237

238
    def count(self, __value):
2✔
UNCOV
239
        return self.__read_seq.count(__value)
×
240

241
    def copy(self):
2✔
UNCOV
242
        while True:
×
243
            try:
×
UNCOV
244
                return self.__write_seq.copy()
×
UNCOV
245
            except AttributeError:
×
246
                self.__clone()
×
247

248
    def __getitem__(self, __i):
2✔
249
        return self.__read_seq.__getitem__(__i)
2✔
250

251
    def append(self, __object):
2✔
252
        while True:
2✔
253
            try:
2✔
254
                return self.__write_seq.append(__object)
2✔
255
            except AttributeError:
2✔
256
                self.__clone()
2✔
257

258
    def extend(self, __iterable):
2✔
259
        while True:
×
260
            try:
×
261
                return self.__write_seq.extend(__iterable)
×
262
            except AttributeError:
×
UNCOV
263
                self.__clone()
×
264

265
    def pop(self, __index=None):
2✔
UNCOV
266
        while True:
×
UNCOV
267
            try:
×
UNCOV
268
                return self.__write_seq.pop(__index)
×
UNCOV
269
            except AttributeError:
×
UNCOV
270
                self.__clone()
×
271

272
    def insert(self, __index, __object):
2✔
UNCOV
273
        while True:
×
UNCOV
274
            try:
×
275
                return self.__write_seq.insert(__index, __object)
×
276
            except AttributeError:
×
277
                self.__clone()
×
278

279
    def remove(self, __value):
2✔
UNCOV
280
        while True:
×
UNCOV
281
            try:
×
282
                return self.__write_seq.remove(__value)
×
283
            except AttributeError:
×
284
                self.__clone()
×
285

286
    def sort(self, *, key=None, reverse=False):
2✔
UNCOV
287
        while True:
×
UNCOV
288
            try:
×
289
                return self.__write_seq.sort(key=key, reverse=reverse)
×
290
            except AttributeError:
×
291
                self.__clone()
×
292

293
    def __setitem__(self, __i, __o):
2✔
UNCOV
294
        while True:
×
UNCOV
295
            try:
×
296
                return self.__write_seq.__setitem__(__i, __o)
×
297
            except AttributeError:
×
298
                self.__clone()
×
299

300
    def __delitem__(self, __i):
2✔
UNCOV
301
        while True:
×
UNCOV
302
            try:
×
303
                return self.__write_seq.__delitem__(__i)
×
304
            except AttributeError:
×
305
                self.__clone()
×
306

307
    def __add__(self, __x):
2✔
UNCOV
308
        while True:
×
UNCOV
309
            try:
×
310
                return self.__write_seq.__add__(__x)
×
311
            except AttributeError:
×
312
                self.__clone()
×
313

314
    def __iadd__(self, __x):
2✔
UNCOV
315
        while True:
×
UNCOV
316
            try:
×
317
                return self.__write_seq.__iadd__(__x)
×
318
            except AttributeError:
×
319
                self.__clone()
×
320

321
    def clear(self):
2✔
UNCOV
322
        while True:
×
UNCOV
323
            try:
×
324
                return self.__write_seq.clear()
×
325
            except AttributeError:
×
326
                self.__clone()
×
327

328
    def reverse(self):
2✔
UNCOV
329
        while True:
×
UNCOV
330
            try:
×
331
                return self.__write_seq.reverse()
×
332
            except AttributeError:
×
333
                self.__clone()
×
334

335
    def __clone(self):
2✔
336
        if self.__read_parent == self.__write_parent:
2✔
337
            self.__write_seq = self.__read_seq
2✔
338
        else:
339
            self.__write_seq = self.__read_seq.copy()
2✔
340
            self.__read_seq = self.__write_seq
2✔
341

342
            setattr(self.__write_parent, self.__name, self.__write_seq)
2✔
343

344

345
class PropertyDict:
2✔
346
    def __init__(self, _dict=None, _parent=None):
2✔
347
        self.__dict__["_PropertyDict__dict"] = _dict or {}
2✔
348
        self.__dict__["_PropertyDict__parent"] = _parent
2✔
349

350
    def __getattr__(self, item):
2✔
351
        v = self.__getattr(item)
2✔
352
        if isinstance(v, _PropertyList):
2✔
353
            v._PropertyList__write_parent = self
2✔
354
        return v
2✔
355

356
    def __getattr(self, item):
2✔
357
        try:
2✔
358
            v = self.__dict[item]
2✔
359
            if isinstance(v, list):
2✔
360
                v = _PropertyList(v, self, item)
2✔
361
            return v
2✔
362
        except KeyError:
2✔
363
            parent = self.__parent
2✔
364
            if parent is not None:
2!
365
                return parent.__getattr(item)
2✔
UNCOV
366
            raise AttributeError("no attribute %r" % item) from None
×
367

368
    def __setattr__(self, key, value):
2✔
369
        if key.startswith("_PropertyDict__"):
2!
UNCOV
370
            raise AttributeError("prohibited attribute %r" % key)
×
371
        if isinstance(value, dict):
2✔
372
            parent_dict = None
2✔
373
            if self.__parent is not None:
2✔
374
                try:
2✔
375
                    parent_dict = self.__parent.__getattr__(key)
2✔
376
                    if not isinstance(parent_dict, PropertyDict):
2!
UNCOV
377
                        raise ValueError("cannot override a scalar with a synthetic object for attribute %s", key)
×
UNCOV
378
                except AttributeError:
×
UNCOV
379
                    pass
×
380
            value = PropertyDict(value, _parent=parent_dict)
2✔
381
        self.__dict[key] = value
2✔
382

383
    def __delattr__(self, item):
2✔
384
        del self.__dict[item]
2✔
385

386
    def __len__(self):
2✔
UNCOV
387
        return len(self.__dir__())
×
388

389
    def __getitem__(self, item):
2✔
UNCOV
390
        return self.__dict.__getitem__(item)
×
391

392
    def __setitem__(self, key, value):
2✔
393
        self.__dict.__setitem__(key, value)
×
394

395
    def __delitem__(self, key):
2✔
UNCOV
396
        self.__dict.__delitem__(key)
×
397

398
    def __contains__(self, item):
2✔
399
        try:
2✔
400
            self.__dict[item]
2✔
401
            return True
2✔
402
        except KeyError:
2✔
403
            parent = self.__parent
2✔
404
            if parent is not None:
2✔
405
                return parent.__contains__(item)
2✔
406
            return False
2✔
407

408
    def __dir__(self) -> Iterable[str]:
2✔
409
        result: set[str] = set()
×
UNCOV
410
        result.update(self.__dict.keys())
×
UNCOV
411
        if self.__parent is not None:
×
412
            result.update(self.__parent.__dir__())
×
UNCOV
413
        return result
×
414

415
    def __repr__(self):
2✔
UNCOV
416
        return "PropertyDict[%r]" % self.__dict
×
417

418

419
def config_parent(config: PropertyDict):
2✔
UNCOV
420
    return config._PropertyDict__parent
×
421

422

423
def config_as_dict(config: PropertyDict):
2✔
UNCOV
424
    return {k: config[k] for k in dir(config)}
×
425

426

427
def config_get(config: PropertyDict, key: str, default=None):
2✔
428
    try:
×
429
        return config[key]
×
UNCOV
430
    except KeyError:
×
UNCOV
431
        return default
×
432

433

434
class Globs(MutableSet[Union[str, re.Pattern]]):
2✔
435
    def __init__(self, source: Optional[list[Union[str, re.Pattern]]] = None,
2✔
436
                 immutable=False):
437
        self._immutable = immutable
2✔
438
        if source:
2!
439
            self._list = [self.__wrap__(v) for v in source]
2!
440
        else:
UNCOV
441
            self._list = []
×
442

443
    def __wrap__(self, item: Union[str, re.Pattern]):
2✔
444
        if isinstance(item, re.Pattern):
2✔
445
            return item
2✔
446
        return re.compile(fnmatch.translate(item))
2✔
447

448
    def __contains__(self, item: Union[str, re.Pattern]):
2✔
UNCOV
449
        return self._list.__contains__(self.__wrap__(item))
×
450

451
    def __iter__(self):
2✔
452
        return self._list.__iter__()
2✔
453

454
    def __len__(self):
2✔
455
        return self._list.__len__()
2✔
456

457
    def add(self, value: Union[str, re.Pattern]):
2✔
458
        if self._immutable:
2!
UNCOV
459
            raise RuntimeError("immutable")
×
460

461
        _list = self._list
2✔
462
        value = self.__wrap__(value)
2✔
463
        if value not in _list:
2!
464
            _list.append(value)
2✔
465

466
    def extend(self, values: Iterable[Union[str, re.Pattern]]):
2✔
UNCOV
467
        for v in values:
×
UNCOV
468
            self.add(v)
×
469

470
    def discard(self, value: Union[str, re.Pattern]):
2✔
471
        if self._immutable:
2!
UNCOV
472
            raise RuntimeError("immutable")
×
473

474
        _list = self._list
2✔
475
        value = self.__wrap__(value)
2✔
476
        if value in _list:
2!
477
            _list.remove(value)
2✔
478

479
    def add_first(self, value: Union[str, re.Pattern]):
2✔
UNCOV
480
        if self._immutable:
×
UNCOV
481
            raise RuntimeError("immutable")
×
482

483
        _list = self._list
×
484
        value = self.__wrap__(value)
×
UNCOV
485
        if value not in _list:
×
UNCOV
486
            _list.insert(0, value)
×
487

488
    def extend_first(self, values: Reversible[Union[str, re.Pattern]]):
2✔
UNCOV
489
        for v in reversed(values):
×
UNCOV
490
            self.add_first(v)
×
491

492
    def __str__(self):
2✔
493
        return self._list.__str__()
2✔
494

495
    def __repr__(self):
2✔
496
        return f"Globs[{self._list}]"
×
497

498

499
class TemplateEngine:
2✔
500
    VARIABLE_START_STRING = "{${"
2✔
501
    VARIABLE_END_STRING = "}$}"
2✔
502

503
    def __init__(self, logger):
2✔
504
        self.template_failures = 0
2✔
505
        self.templates = {}
2✔
506

507
        class CollectingUndefined(ChainableUndefined):
2✔
508
            __slots__ = ()
2✔
509

510
            def __str__(self):
2✔
UNCOV
511
                self.template_failures += 1
×
512
                return super().__str__()
×
513

514
        logging_undefined = make_logging_undefined(
2✔
515
            logger=logger,
516
            base=CollectingUndefined
517
        )
518

519
        @pass_context
2✔
520
        def variable_finalizer(ctx, value):
2✔
521
            normalized_value = str(value)
2✔
522
            if self.VARIABLE_START_STRING in normalized_value and self.VARIABLE_END_STRING in normalized_value:
2✔
523
                value_template_content = sys.intern(normalized_value)
2✔
524
                env: Environment = ctx.environment
2✔
525
                value_template = self.templates.get(value_template_content)
2✔
526
                if not value_template:
2!
527
                    value_template = env.from_string(value_template_content, env.globals)
2✔
528
                    self.templates[value_template_content] = value_template
2✔
529
                return value_template.render(ctx.parent)
2✔
530

531
            return normalized_value
2✔
532

533
        self.env = Environment(variable_start_string=self.VARIABLE_START_STRING,
2✔
534
                               variable_end_string=self.VARIABLE_END_STRING,
535
                               autoescape=False,
536
                               finalize=variable_finalizer,
537
                               undefined=logging_undefined)
538

539
    def from_string(self, template):
2✔
540
        return self.env.from_string(template)
2✔
541

542
    def failures(self):
2✔
543
        return self.template_failures
2✔
544

545

546
class Template:
2✔
547
    def __init__(self, name: str, template: JinjaTemplate, defaults: dict = None, path=None, source=None):
2✔
548
        self.name = name
2✔
549
        self.source = source
2✔
550
        self.path = path
2✔
551
        self.template = template
2✔
552
        self.defaults = defaults
2✔
553

554
    def render(self, context: dict, values: dict):
2✔
555
        variables = {"ktor": context,
2✔
556
                     "values": (self.defaults or {}) | values}
557
        return self.template.render(variables)
2✔
558

559

560
class StringIO:
2✔
561
    def __init__(self, trimmed=True):
2✔
562
        self.write = self.write_trimmed if trimmed else self.write_untrimmed
2✔
563
        self._buf = io_StringIO()
2✔
564

565
    def write_untrimmed(self, line):
2✔
566
        self._buf.write(line)
2✔
567

568
    def write_trimmed(self, line):
2✔
UNCOV
569
        self._buf.write(f"{line}\n")
×
570

571
    def getvalue(self):
2✔
572
        return self._buf.getvalue()
2✔
573

574

575
class StripNL:
2✔
576
    def __init__(self, func):
2✔
577
        self._func = func
2✔
578

579
    def __call__(self, line: str):
2✔
580
        return self._func(line.rstrip("\r\n"))
2✔
581

582

583
def log_level_to_verbosity_count(level: int):
2✔
584
    return int(-level / 10 + 6)
2✔
585

586

587
def clone_url_str(url):
2✔
588
    return urllib.parse.urlunsplit(url[:3] + ("", ""))  # no query or fragment
2✔
589

590

591
def prepend_os_path(path):
2✔
592
    path = str(path)
2✔
593
    paths = os.environ["PATH"].split(os.pathsep)
2✔
594
    if path not in paths:
2!
595
        paths.insert(0, path)
2✔
596
        os.environ["PATH"] = os.pathsep.join(paths)
2✔
597
        return True
2✔
UNCOV
598
    return False
×
599

600

601
_GOLANG_MACHINE = platform.machine().lower()
2✔
602
if _GOLANG_MACHINE == "x86_64":
2!
603
    _GOLANG_MACHINE = "amd64"
2✔
604

605
_GOLANG_OS = platform.system().lower()
2✔
606

607

608
def get_golang_machine():
2✔
609
    return _GOLANG_MACHINE
2✔
610

611

612
def get_golang_os():
2✔
613
    return _GOLANG_OS
2✔
614

615

616
def sha256_file_digest(path):
2✔
UNCOV
617
    h = sha256()
×
UNCOV
618
    with open(path, "rb") as f:
×
UNCOV
619
        h.update(f.read(65535))
×
UNCOV
620
    return h.hexdigest()
×
621

622

623
class Repository:
2✔
624
    logger = logging.getLogger("kubernator.repository")
2✔
625
    git_logger = logger.getChild("git")
2✔
626

627
    def __init__(self, repo, cred_aug=None):
2✔
628
        repo = str(repo)  # in case this is a Path
2✔
629
        url = urllib.parse.urlsplit(repo)
2✔
630

631
        if not url.scheme and not url.netloc and Path(url.path).exists():
2!
632
            url = url._replace(scheme="file")  # In case it's a local repository
2✔
633

634
        self.url = url
2✔
635
        self.url_str = urllib.parse.urlunsplit(url[:4] + ("",))
2✔
636
        self._cred_aug = cred_aug
2✔
637
        self._hash_obj = (url.hostname if url.username or url.password else url.netloc,
2✔
638
                          url.path,
639
                          url.query)
640

641
        self.clone_url = None  # Actual URL components used in cloning operations
2✔
642
        self.clone_url_str = None  # Actual URL string used in cloning operations
2✔
643
        self.ref = None
2✔
644
        self.local_dir = None
2✔
645

646
    def __eq__(self, o: object) -> bool:
2✔
UNCOV
647
        if isinstance(o, Repository):
×
UNCOV
648
            return self._hash_obj == o._hash_obj
×
649

650
    def __hash__(self) -> int:
2✔
651
        return hash(self._hash_obj)
2✔
652

653
    def init(self, logger, context):
2✔
654
        run = context.app.run
2✔
655
        run_capturing_out = context.app.run_capturing_out
2✔
656

657
        url = self.url
2✔
658
        if self._cred_aug:
2!
659
            url = self._cred_aug(url)
2✔
660

661
        self.clone_url = url
2✔
662
        self.clone_url_str = clone_url_str(url)
2✔
663

664
        query = urllib.parse.parse_qs(self.url.query)
2✔
665
        ref = query.get("ref")
2✔
666
        if ref:
2✔
667
            self.ref = ref[0]
2✔
668

669
        config_dir = get_cache_dir("git")
2✔
670

671
        git_cache = config_dir / sha256(self.clone_url_str.encode("UTF-8")).hexdigest()
2✔
672

673
        if git_cache.exists() and git_cache.is_dir() and (git_cache / ".git").exists():
2✔
674
            try:
2✔
675
                run(["git", "status"], None, None, cwd=git_cache).wait()
2✔
UNCOV
676
            except CalledProcessError:
×
UNCOV
677
                rmtree(git_cache)
×
678

679
        self.local_dir = git_cache
2✔
680

681
        stdout_logger = StripNL(self.git_logger.debug)
2✔
682
        stderr_logger = StripNL(self.git_logger.info)
2✔
683
        if git_cache.exists():
2✔
684
            if not self.ref:
2!
UNCOV
685
                ref = run_capturing_out(["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
×
686
                                        stderr_logger, cwd=git_cache).strip()[7:]  # Remove prefix "origin/"
687
            else:
688
                ref = self.ref
2✔
689
            self.logger.info("Using %s%s cached in %s", self.url_str,
2✔
690
                             f"?ref={ref}" if not self.ref else "",
691
                             self.local_dir)
692
            run(["git", "config", "remote.origin.fetch", f"+refs/heads/{ref}:refs/remotes/origin/{ref}"],
2✔
693
                stdout_logger, stderr_logger, cwd=git_cache).wait()
694
            run(["git", "fetch", "-pPt", "--force"], stdout_logger, stderr_logger, cwd=git_cache).wait()
2✔
695
            run(["git", "checkout", ref], stdout_logger, stderr_logger, cwd=git_cache).wait()
2✔
696
            run(["git", "clean", "-f"], stdout_logger, stderr_logger, cwd=git_cache).wait()
2✔
697
            run(["git", "reset", "--hard", ref, "--"], stdout_logger, stderr_logger, cwd=git_cache).wait()
2✔
698
            run(["git", "pull"], stdout_logger, stderr_logger, cwd=git_cache).wait()
2✔
699
        else:
700
            self.logger.info("Initializing %s -> %s", self.url_str, self.local_dir)
2✔
701
            args = (["git", "clone", "--depth", "1",
2✔
702
                     "-" + ("v" * log_level_to_verbosity_count(logger.getEffectiveLevel()))] +
703
                    (["-b", self.ref] if self.ref else []) +
704
                    ["--", self.clone_url_str, str(self.local_dir)])
705
            safe_args = [c if c != self.clone_url_str else self.url_str for c in args]
2!
706
            run(args, stdout_logger, stderr_logger, safe_args=safe_args).wait()
2✔
707

708
    def cleanup(self):
2✔
709
        if False and self.local_dir:
2✔
710
            self.logger.info("Cleaning up %s -> %s", self.url_str, self.local_dir)
711
            rmtree(self.local_dir)
712

713

714
class KubernatorPlugin:
2✔
715
    _name = None
2✔
716

717
    def set_context(self, context):
2✔
UNCOV
718
        raise NotImplementedError
×
719

720
    def register(self, **kwargs):
2✔
721
        pass
2✔
722

723
    def handle_init(self):
2✔
724
        pass
2✔
725

726
    def handle_start(self):
2✔
727
        pass
2✔
728

729
    def handle_before_dir(self, cwd: Path):
2✔
730
        pass
2✔
731

732
    def handle_before_script(self, cwd: Path):
2✔
733
        pass
2✔
734

735
    def handle_after_script(self, cwd: Path):
2✔
736
        pass
2✔
737

738
    def handle_after_dir(self, cwd: Path):
2✔
739
        pass
2✔
740

741
    def handle_apply(self):
2✔
742
        pass
2✔
743

744
    def handle_verify(self):
2✔
745
        pass
2✔
746

747
    def handle_shutdown(self):
2✔
748
        pass
2✔
749

750
    def handle_summary(self):
2✔
751
        pass
2✔
752

753

754
def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_stderr, disable_patching):
2✔
755
    cache_dir = get_cache_dir("python")
2✔
756
    package_major_dir = cache_dir / str(package_major)
2✔
757
    package_major_dir_str = str(package_major_dir)
2✔
758
    patch_indicator = package_major_dir / ".patched"
2✔
759

760
    if disable_patching and package_major_dir.exists() and patch_indicator.exists():
2✔
761
        logger.info("Patching is disabled, existing Kubernetes Client %s (%s) was patched - "
2✔
762
                    "deleting current client",
763
                    str(package_major), package_major_dir)
764
        rmtree(package_major_dir)
2✔
765

766
    if not package_major_dir.exists():
2✔
767
        package_major_dir.mkdir(parents=True, exist_ok=True)
2✔
768
        run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input",
2✔
769
             "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
770
             "--target", package_major_dir_str, f"kubernetes>={package_major!s}dev0,<{int(package_major) + 1!s}"],
771
            logger_stdout, logger_stderr).wait()
772

773
    if not patch_indicator.exists() and not disable_patching:
2✔
774
        for patch_text, target_file, skip_if_found, min_version, max_version, name in (
2✔
775
                URLLIB_HEADERS_PATCH, CUSTOM_OBJECT_PATCH_23, CUSTOM_OBJECT_PATCH_25):
776
            patch_target = package_major_dir / target_file
2✔
777
            logger.info("Applying patch %s to %s...", name, patch_target)
2✔
778
            if min_version and int(package_major) < min_version:
2✔
779
                logger.info("Skipping patch %s on %s due to package major version %s below minimum %d!",
2✔
780
                            name, patch_target, package_major, min_version)
781
                continue
2✔
782
            if max_version and int(package_major) > max_version:
2✔
783
                logger.info("Skipping patch %s on %s due to package major version %s above maximum %d!",
2✔
784
                            name, patch_target, package_major, max_version)
785
                continue
2✔
786

787
            with open(patch_target, "rt") as f:
2✔
788
                target_file_original = f.read()
2✔
789
            if skip_if_found in target_file_original:
2✔
790
                logger.info("Skipping patch %s on %s, as it already appears to be patched!", name,
2✔
791
                            patch_target)
792
                continue
2✔
793

794
            dmp = diff_match_patch()
2✔
795
            patches = dmp.patch_fromText(patch_text)
2✔
796
            target_file_patched, results = dmp.patch_apply(patches, target_file_original)
2✔
797
            failed_patch = False
2✔
798
            for idx, result in enumerate(results):
2✔
799
                if not result:
2!
UNCOV
800
                    failed_patch = True
×
UNCOV
801
                    msg = ("Failed to apply a patch to Kubernetes Client API %s, hunk #%d, patch: \n%s" % (
×
802
                        patch_target, idx, patches[idx]))
UNCOV
803
                    logger.fatal(msg)
×
804
            if failed_patch:
2!
UNCOV
805
                raise RuntimeError(f"Failed to apply some Kubernetes Client API {patch_target} patches")
×
806

807
            with open(patch_target, "wt") as f:
2✔
808
                f.write(target_file_patched)
2✔
809

810
        patch_indicator.touch(exist_ok=False)
2✔
811

812
    return package_major_dir
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc