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

karellen / kubernator / 24637370495

19 Apr 2026 07:35PM UTC coverage: 82.036% (+0.04%) from 81.992%
24637370495

push

github

web-flow
Add project plugin v1: scope, ownership, cleanup (#103)

## Summary

Introduces a **project plugin** that adds hierarchical scope, ownership
tracking, and cleanup to Kubernator. Kubernator today is stateless —
every run reconciles everything it discovers and cannot tell whether a
resource it sees was applied by us, by someone else, or by a previous
run that has since dropped the manifest. Project v1 fixes all three:

- **Scope.** A user registers the project plugin once at the root with a
name; sub-directories extend the project via ``ktor.app.project =
\"<segment>\"`` (dot-joined top-down). CLI flags ``-I``/``-X`` scope a
run to specific sub-trees (repeatable, combineable: ``candidates = known
∩ includes`` then ``in_scope = candidates − excludes``).
- **Ownership.** Every applied resource is stamped with a
``kubernator.io/project`` annotation carrying the composed path. A
single gzipped JSON Secret per top-level project
(``<state_namespace>/kubernator-project-<sha1(root)[:12]>``) records the
idents of resources we own.
- **Cleanup.** On each run, the prior Secret's idents are diffed against
the current in-scope manifests; when ``cleanup=True``, resources that
used to be ours but are no longer in scope get deleted. Resources that
moved between sub-projects within the run's in-scope set are detected
and preserved (dedup on identity, ignoring project).

### Crash resilience

The Secret write is two-phase: before ``handle_apply`` runs, the new
intent is recorded as ``pending`` with ``finalized=false`` while
``resources`` still holds the prior finalized set. After
``handle_cleanup`` succeeds, Finalize commits the merged ``resources``
and clears ``pending`` with ``finalized=true``. A crash between the two
phases leaves ``finalized=false``; the next run's cleanup takes the
conservative union (``resources ∪ pending``) so no previously-owned
resource ever slips past cleanup.

### Concurrent-run protection

A ``coordination.k8s.io/v1.Lease`` named
``kubernator... (continued)

1078 of 1494 branches covered (72.16%)

Branch coverage included in aggregate %.

443 of 561 new or added lines in 5 files covered. (78.97%)

3 existing lines in 1 file now uncovered.

4434 of 5225 relevant lines covered (84.86%)

4.23 hits per line

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

74.29
/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 textwrap
5✔
27
import traceback
5✔
28
import urllib.parse
5✔
29
from collections.abc import Callable
5✔
30
from collections.abc import Iterable, MutableSet, Reversible
5✔
31
from enum import Enum
5✔
32
from hashlib import sha256
5✔
33
from io import StringIO as io_StringIO
5✔
34
from pathlib import Path
5✔
35
from shutil import rmtree
5✔
36
from subprocess import CalledProcessError
5✔
37
from types import GeneratorType
5✔
38
from typing import Optional, Union, MutableSequence
5✔
39

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

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

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

61

62
def to_json(obj: Union[dict, list]):
5✔
63
    return json.dumps(obj)
5✔
64

65

66
def to_yaml_str(s: str):
5✔
67
    return yaml.safe_dump(s)
5✔
68

69

70
def to_json_yaml_str(obj: Union[dict, list]):
5✔
71
    """
72
    Takes `obj`, dumps as json representation, converts json representation to YAML string literal.
73
    """
74
    return to_yaml_str(to_json(obj))
5✔
75

76

77
def to_yaml_str_block(s: str, indent: int = 4, pretty_indent: int = 2):
5✔
78
    """
79
    Takes a multiline string, dedents it then indents it `indent` spaces for in-yaml alignment and
80
    `pretty-indent` spaces for in-block alignment.
81
    """
82
    return (f"|+{pretty_indent}\n" +
5✔
83
            textwrap.indent(textwrap.dedent(s), " " * (indent + pretty_indent)))
84

85

86
def to_json_yaml_str_block(obj: Union[str, dict, list], indent: int = 4, pretty_indent=2):
5✔
87
    """
88
    Takes an `obj`, serializes it as pretty JSON with `pretty_indent` in-json indentation and then
89
    passes it to `to_yaml_str_block`.
90
    """
91
    return to_yaml_str_block(json.dumps(obj, indent=pretty_indent), indent=indent, pretty_indent=pretty_indent)
5✔
92

93

94
def to_yaml(obj: Union[dict, list], level_indent: int, indent: int):
5✔
95
    s = yaml.safe_dump(obj, indent=indent)
5✔
96
    return "\n" + textwrap.indent(s, " " * level_indent)
5✔
97

98

99
class TemplateEngine:
5✔
100
    VARIABLE_START_STRING = "{${"
5✔
101
    VARIABLE_END_STRING = "}$}"
5✔
102

103
    def __init__(self, logger):
5✔
104
        self.template_failures = 0
5✔
105
        self.templates = {}
5✔
106

107
        class CollectingUndefined(ChainableUndefined):
5✔
108
            __slots__ = ()
5✔
109

110
            def __str__(self):
5✔
111
                self.template_failures += 1
×
112
                return super().__str__()
×
113

114
        logging_undefined = make_logging_undefined(
5✔
115
            logger=logger,
116
            base=CollectingUndefined
117
        )
118

119
        @pass_context
5✔
120
        def variable_finalizer(ctx, value):
5✔
121
            normalized_value = str(value)
5✔
122
            if self.VARIABLE_START_STRING in normalized_value and self.VARIABLE_END_STRING in normalized_value:
5✔
123
                value_template_content = sys.intern(normalized_value)
5✔
124
                env: Environment = ctx.environment
5✔
125
                value_template = self.templates.get(value_template_content)
5✔
126
                if not value_template:
5!
127
                    value_template = env.from_string(value_template_content, env.globals)
5✔
128
                    self.templates[value_template_content] = value_template
5✔
129
                return value_template.render(ctx.parent)
5✔
130

131
            return normalized_value
5✔
132

133
        self.env = Environment(variable_start_string=self.VARIABLE_START_STRING,
5✔
134
                               variable_end_string=self.VARIABLE_END_STRING,
135
                               autoescape=False,
136
                               finalize=variable_finalizer,
137
                               undefined=logging_undefined,
138
                               )
139

140
        self.env.filters["to_json"] = to_json
5✔
141
        self.env.filters["to_yaml_str"] = to_yaml_str
5✔
142
        self.env.filters["to_yaml"] = to_yaml
5✔
143
        self.env.filters["to_yaml_str_block"] = to_yaml_str_block
5✔
144
        self.env.filters["to_json_yaml_str_block"] = to_json_yaml_str_block
5✔
145
        self.env.filters["to_json_yaml_str"] = to_json_yaml_str
5✔
146

147
    def from_string(self, template):
5✔
148
        return self.env.from_string(template)
5✔
149

150
    def failures(self):
5✔
151
        return self.template_failures
5✔
152

153

154
def calling_frame_source(depth=2):
5✔
155
    f = traceback.extract_stack(limit=depth + 1)[0]
5✔
156
    return f"file {f.filename}, line {f.lineno} in {f.name}"
5✔
157

158

159
def re_filter(name: str, patterns: Iterable[re.Pattern]):
5✔
160
    for pattern in patterns:
5✔
161
        if pattern.match(name):
5✔
162
            return True
5✔
163

164

165
def to_patterns(*patterns):
5✔
166
    return [re.compile(fnmatch.translate(p)) for p in patterns]
×
167

168

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

175
    for include in includes:
5✔
176
        logger.trace("Considering include %s in %s", include, path)
5✔
177
        for f in list(files.keys()):
5✔
178
            if include.match(f):
5✔
179
                del files[f]
5✔
180
                logger.debug("Selecting %s in %s as it matches %s", f, path, include)
5✔
181
                yield path / f
5✔
182

183

184
def parse_yaml_docs(document: str, source=None):
5✔
185
    try:
5✔
186
        return list(d for d in yaml.safe_load_all(document) if d)
5✔
187
    except MarkedYAMLError:
×
188
        raise
×
189

190

191
class FileType(Enum):
5✔
192
    TEXT = (lambda x: x,)
5✔
193
    BINARY = (lambda x: x,)
5✔
194
    JSON = (json.loads,)
5✔
195
    YAML = (parse_yaml_docs,)
5✔
196

197
    def __init__(self, func):
5✔
198
        self.func = func
5✔
199

200

201
def _load_file(logger, path: Path, file_type: FileType, source=None,
5✔
202
               template_engine: Optional[TemplateEngine] = None,
203
               template_context: Optional[dict] = None) -> Iterable[dict]:
204
    with open(path, "rb" if file_type == FileType.BINARY else "rt") as f:
5✔
205
        try:
5✔
206
            if template_engine and not file_type == FileType.BINARY:
5✔
207
                raw_data = template_engine.from_string(f.read()).render(template_context)
5✔
208
            else:
209
                raw_data = f.read()
5✔
210
            data = file_type.func(raw_data)
5✔
211
            if isinstance(data, GeneratorType):
5!
212
                data = list(data)
×
213
            return data
5✔
214
        except Exception as e:
×
215
            logger.error("Failed parsing %s using %s", source or path, file_type, exc_info=e)
×
216
            raise
×
217

218

219
def _download_remote_file(url, file_name, cache: dict):
5✔
220
    retry_delay = 0
5✔
221
    while True:
5✔
222
        if retry_delay:
5!
223
            sleep(retry_delay)
×
224

225
        with requests.get(url, headers=cache, stream=True) as r:
5✔
226
            if r.status_code == 429:
5!
227
                if not retry_delay:
×
228
                    retry_delay = 0.2
×
229
                else:
230
                    retry_delay *= 2.0
×
231
                if retry_delay > 2.5:
×
232
                    retry_delay = 2.5
×
233
                continue
×
234

235
            r.raise_for_status()
5✔
236
            if r.status_code != 304:
5!
237
                with open(file_name, "wb") as out:
×
238
                    for chunk in r.iter_content(chunk_size=65535):
×
239
                        out.write(chunk)
×
240
                return dict(r.headers)
×
241
            else:
242
                return None
5✔
243

244

245
def get_app_cache_dir():
5✔
246
    return Path(user_cache_dir("kubernator", "karellen"))
5✔
247

248

249
def get_cache_dir(category: str, sub_category: str = None):
5✔
250
    cache_dir = get_app_cache_dir() / category
5✔
251
    if sub_category:
5!
252
        cache_dir = cache_dir / sub_category
×
253
    if not cache_dir.exists():
5✔
254
        cache_dir.mkdir(parents=True)
5✔
255

256
    return cache_dir
5✔
257

258

259
def download_remote_file(logger, url: str, category: str = "k8s", sub_category: str = None,
5✔
260
                         downloader=_download_remote_file):
261
    config_dir = get_cache_dir(category, sub_category)
5✔
262

263
    file_name = config_dir / sha256(url.encode("UTF-8")).hexdigest()
5✔
264
    cache_file_name = file_name.with_suffix(".cache")
5✔
265
    logger.trace("Cache file for %s is %s.cache", url, file_name)
5✔
266

267
    cache = {}
5✔
268
    if cache_file_name.exists():
5!
269
        logger.trace("Loading cache file from %s", cache_file_name)
5✔
270
        try:
5✔
271
            with open(cache_file_name, "rb") as cache_f:
5✔
272
                cache = json.load(cache_f)
5✔
273
        except (IOError, ValueError) as e:
×
274
            logger.trace("Failed loading cache file from %s (cleaning up)", cache_file_name, exc_info=e)
×
275
            cache_file_name.unlink(missing_ok=True)
×
276

277
    logger.trace("Downloading %s into %s%s", url, file_name, " (caching)" if cache else "")
5✔
278
    headers = downloader(url, file_name, cache)
5✔
279
    up_to_date = False
5✔
280
    if not headers:
5!
281
        logger.trace("File %s(%s) is up-to-date", url, file_name.name)
5✔
282
        up_to_date = True
5✔
283
    else:
284
        cache = {_CACHE_HEADER_TRANSLATION.get(k.lower(), k): v
×
285
                 for k, v in headers.items()
286
                 if k.lower() in _CACHE_HEADERS}
287

288
        logger.trace("Update cache file in %s: %r", cache_file_name, cache)
×
289
        with open(cache_file_name, "wt") as cache_f:
×
290
            json.dump(cache, cache_f)
×
291

292
    return file_name, up_to_date
5✔
293

294

295
def load_remote_file(logger, url, file_type: FileType, category: str = "k8s", sub_category: str = None,
5✔
296
                     downloader=_download_remote_file):
297
    file_name, _ = download_remote_file(logger, url, category, sub_category, downloader=downloader)
5✔
298
    logger.debug("Loading %s from %s using %s", url, file_name, file_type.name)
5✔
299
    return _load_file(logger, file_name, file_type, url)
5✔
300

301

302
def load_file(logger, path: Path, file_type: FileType, source=None,
5✔
303
              template_engine: Optional[TemplateEngine] = None,
304
              template_context: Optional[dict] = None) -> Iterable[dict]:
305
    logger.debug("Loading %s using %s", source or path, file_type.name)
5✔
306
    return _load_file(logger, path, file_type,
5✔
307
                      source, template_engine, template_context)
308

309

310
def validator_with_defaults(validator_class):
5✔
311
    validate_properties = validator_class.VALIDATORS["properties"]
5✔
312

313
    def set_defaults(validator, properties, instance, schema):
5✔
314
        for property, subschema in properties.items():
5✔
315
            if "default" in subschema:
5✔
316
                instance.setdefault(property, subschema["default"])
5✔
317

318
        for error in validate_properties(validator, properties, instance, schema):
5!
319
            yield error
×
320

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

323

324
class _PropertyList(MutableSequence):
5✔
325

326
    def __init__(self, seq, read_parent, name):
5✔
327
        self.__read_seq = seq
5✔
328
        self.__read_parent = read_parent
5✔
329
        self.__write_parent = None
5✔
330
        self.__write_seq = None
5✔
331
        self.__name = name
5✔
332

333
    def __iter__(self):
5✔
334
        return self.__read_seq.__iter__()
5✔
335

336
    def __mul__(self, __n):
5✔
337
        return self.__read_seq.__mul__(__n)
×
338

339
    def __rmul__(self, __n):
5✔
340
        return self.__read_seq.__rmul__(__n)
×
341

342
    def __imul__(self, __n):
5✔
343
        return self.__read_seq.__imul__(__n)
×
344

345
    def __contains__(self, __o):
5✔
346
        return self.__read_seq.__contains__(__o)
×
347

348
    def __reversed__(self):
5✔
349
        return self.__read_seq.__reversed__()
5✔
350

351
    def __gt__(self, __x):
5✔
352
        return self.__read_seq.__gt__(__x)
×
353

354
    def __ge__(self, __x):
5✔
355
        return self.__read_seq.__ge__(__x)
×
356

357
    def __lt__(self, __x):
5✔
358
        return self.__read_seq.__lt__(__x)
×
359

360
    def __le__(self, __x):
5✔
361
        return self.__read_seq.__le__(__x)
×
362

363
    def __len__(self):
5✔
364
        return self.__read_seq.__len__()
5✔
365

366
    def count(self, __value):
5✔
367
        return self.__read_seq.count(__value)
×
368

369
    def copy(self):
5✔
370
        while True:
×
371
            try:
×
372
                return self.__write_seq.copy()
×
373
            except AttributeError:
×
374
                self.__clone()
×
375

376
    def __getitem__(self, __i):
5✔
377
        return self.__read_seq.__getitem__(__i)
5✔
378

379
    def append(self, __object):
5✔
380
        while True:
5✔
381
            try:
5✔
382
                return self.__write_seq.append(__object)
5✔
383
            except AttributeError:
5✔
384
                self.__clone()
5✔
385

386
    def extend(self, __iterable):
5✔
387
        while True:
×
388
            try:
×
389
                return self.__write_seq.extend(__iterable)
×
390
            except AttributeError:
×
391
                self.__clone()
×
392

393
    def pop(self, __index=None):
5✔
394
        while True:
×
395
            try:
×
396
                return self.__write_seq.pop(__index)
×
397
            except AttributeError:
×
398
                self.__clone()
×
399

400
    def insert(self, __index, __object):
5✔
401
        while True:
×
402
            try:
×
403
                return self.__write_seq.insert(__index, __object)
×
404
            except AttributeError:
×
405
                self.__clone()
×
406

407
    def remove(self, __value):
5✔
408
        while True:
×
409
            try:
×
410
                return self.__write_seq.remove(__value)
×
411
            except AttributeError:
×
412
                self.__clone()
×
413

414
    def sort(self, *, key=None, reverse=False):
5✔
415
        while True:
×
416
            try:
×
417
                return self.__write_seq.sort(key=key, reverse=reverse)
×
418
            except AttributeError:
×
419
                self.__clone()
×
420

421
    def __setitem__(self, __i, __o):
5✔
422
        while True:
×
423
            try:
×
UNCOV
424
                return self.__write_seq.__setitem__(__i, __o)
×
425
            except AttributeError:
×
UNCOV
426
                self.__clone()
×
427

428
    def __delitem__(self, __i):
5✔
429
        while True:
×
430
            try:
×
431
                return self.__write_seq.__delitem__(__i)
×
432
            except AttributeError:
×
433
                self.__clone()
×
434

435
    def __add__(self, __x):
5✔
436
        while True:
×
437
            try:
×
438
                return self.__write_seq.__add__(__x)
×
439
            except AttributeError:
×
440
                self.__clone()
×
441

442
    def __iadd__(self, __x):
5✔
443
        while True:
×
444
            try:
×
445
                return self.__write_seq.__iadd__(__x)
×
446
            except AttributeError:
×
447
                self.__clone()
×
448

449
    def clear(self):
5✔
450
        while True:
×
451
            try:
×
452
                return self.__write_seq.clear()
×
453
            except AttributeError:
×
454
                self.__clone()
×
455

456
    def reverse(self):
5✔
457
        while True:
×
458
            try:
×
459
                return self.__write_seq.reverse()
×
460
            except AttributeError:
×
461
                self.__clone()
×
462

463
    def __clone(self):
5✔
464
        if self.__read_parent == self.__write_parent:
5✔
465
            self.__write_seq = self.__read_seq
5✔
466
        else:
467
            self.__write_seq = self.__read_seq.copy()
5✔
468
            self.__read_seq = self.__write_seq
5✔
469

470
            setattr(self.__write_parent, self.__name, self.__write_seq)
5✔
471

472

473
class ContextProperty:
5✔
474
    """Callable descriptor stored as a value in a PropertyDict. When any
475
    PropertyDict in the ancestor chain holds a ``ContextProperty`` under the
476
    accessed name, attribute access is dispatched here with the *origin*
477
    PropertyDict (the one the access started from), so the descriptor can
478
    walk the chain upward itself."""
479

480
    __slots__ = ("fget", "fset")
5✔
481

482
    def __init__(self, fget, fset=None):
5✔
483
        self.fget = fget
5✔
484
        self.fset = fset
5✔
485

486

487
class PropertyDict:
5✔
488
    # Names ever installed as a ``ContextProperty`` on any PropertyDict. The
489
    # fast path in ``__getattr__``/``__setattr__`` skips the full-chain walk
490
    # for names that are never descriptors — which is the overwhelming
491
    # majority of attribute accesses in practice.
492
    _descriptor_names: set = set()
5✔
493

494
    def __init__(self, _dict=None, _parent=None):
5✔
495
        self.__dict__["_PropertyDict__dict"] = _dict or {}
5✔
496
        self.__dict__["_PropertyDict__parent"] = _parent
5✔
497

498
    def __getattr__(self, item):
5✔
499
        if item in PropertyDict._descriptor_names:
5✔
500
            v = self.__descriptor_getattr(item, self)
5✔
501
        else:
502
            v = self.__plain_getattr(item)
5✔
503
        if isinstance(v, _PropertyList):
5✔
504
            v._PropertyList__write_parent = self
5✔
505
        return v
5✔
506

507
    def __plain_getattr(self, item):
5✔
508
        try:
5✔
509
            v = self.__dict[item]
5✔
510
            if isinstance(v, list):
5✔
511
                v = _PropertyList(v, self, item)
5✔
512
            return v
5✔
513
        except KeyError:
5✔
514
            parent = self.__parent
5✔
515
            if parent is not None:
5!
516
                return parent._PropertyDict__plain_getattr(item)
5✔
517
            raise AttributeError("no attribute %r" % item) from None
×
518

519
    def __descriptor_getattr(self, item, origin):
5✔
520
        first_layer = None
5✔
521
        first_value = None
5✔
522
        found_raw = False
5✔
523
        cur = self
5✔
524
        while cur is not None:
5✔
525
            d = cur._PropertyDict__dict
5✔
526
            if item in d:
5✔
527
                v = d[item]
5✔
528
                if isinstance(v, ContextProperty):
5✔
529
                    return v.fget(origin)
5✔
530
                if not found_raw:
5✔
531
                    first_layer = cur
5✔
532
                    first_value = v
5✔
533
                    found_raw = True
5✔
534
            cur = cur._PropertyDict__parent
5✔
535
        if found_raw:
5!
536
            if isinstance(first_value, list):
5!
NEW
537
                return _PropertyList(first_value, first_layer, item)
×
538
            return first_value
5✔
NEW
539
        raise AttributeError("no attribute %r" % item) from None
×
540

541
    def __setattr__(self, key, value):
5✔
542
        if key.startswith("_PropertyDict__"):
5!
543
            raise AttributeError("prohibited attribute %r" % key)
×
544
        if key in PropertyDict._descriptor_names:
5✔
545
            cur = self
5✔
546
            while cur is not None:
5✔
547
                d = cur._PropertyDict__dict
5✔
548
                if key in d and isinstance(d[key], ContextProperty):
5✔
549
                    descriptor = d[key]
5✔
550
                    if descriptor.fset is None:
5✔
551
                        raise AttributeError("%s is read-only" % key)
5✔
552
                    descriptor.fset(self, value)
5✔
553
                    return
5✔
554
                cur = cur._PropertyDict__parent
5✔
555
        if isinstance(value, ContextProperty):
5✔
556
            PropertyDict._descriptor_names.add(key)
5✔
557
        if isinstance(value, dict):
5✔
558
            parent_dict = None
5✔
559
            if self.__parent is not None:
5✔
560
                try:
5✔
561
                    parent_dict = self.__parent.__getattr__(key)
5✔
562
                    if not isinstance(parent_dict, PropertyDict):
5!
563
                        raise ValueError("cannot override a scalar with a synthetic object for attribute %s", key)
×
564
                except AttributeError:
×
565
                    pass
×
566
            value = PropertyDict(value, _parent=parent_dict)
5✔
567
        self.__dict[key] = value
5✔
568

569
    def __delattr__(self, item):
5✔
570
        del self.__dict[item]
5✔
571

572
    def __len__(self):
5✔
573
        return len(self.__dir__())
×
574

575
    def __getitem__(self, item):
5✔
576
        return self.__dict.__getitem__(item)
5✔
577

578
    def __setitem__(self, key, value):
5✔
UNCOV
579
        self.__dict.__setitem__(key, value)
×
580

581
    def __delitem__(self, key):
5✔
582
        self.__dict.__delitem__(key)
×
583

584
    def __contains__(self, item):
5✔
585
        try:
5✔
586
            self.__dict[item]
5✔
587
            return True
5✔
588
        except KeyError:
5✔
589
            parent = self.__parent
5✔
590
            if parent is not None:
5✔
591
                return parent.__contains__(item)
5✔
592
            return False
5✔
593

594
    def __dir__(self) -> Iterable[str]:
5✔
595
        result: set[str] = set()
×
596
        result.update(self.__dict.keys())
×
597
        if self.__parent is not None:
×
598
            result.update(self.__parent.__dir__())
×
599
        return result
×
600

601
    def __repr__(self):
5✔
602
        return "PropertyDict[%r]" % self.__dict
×
603

604

605
def config_parent(config: PropertyDict):
5✔
606
    return config._PropertyDict__parent
5✔
607

608

609
def config_as_dict(config: PropertyDict):
5✔
610
    return {k: config[k] for k in dir(config)}
×
611

612

613
def config_get(config: PropertyDict, key: str, default=None):
5✔
614
    try:
5✔
615
        return config[key]
5✔
616
    except KeyError:
×
617
        return default
×
618

619

620
class Globs(MutableSet[Union[str, re.Pattern]]):
5✔
621
    def __init__(self, source: Optional[list[Union[str, re.Pattern]]] = None,
5✔
622
                 immutable=False):
623
        self._immutable = immutable
5✔
624
        if source:
5!
625
            self._list = [self.__wrap__(v) for v in source]
5✔
626
        else:
627
            self._list = []
×
628

629
    def __wrap__(self, item: Union[str, re.Pattern]):
5✔
630
        if isinstance(item, re.Pattern):
5✔
631
            return item
5✔
632
        return re.compile(fnmatch.translate(item))
5✔
633

634
    def __contains__(self, item: Union[str, re.Pattern]):
5✔
635
        return self._list.__contains__(self.__wrap__(item))
×
636

637
    def __iter__(self):
5✔
638
        return self._list.__iter__()
5✔
639

640
    def __len__(self):
5✔
641
        return self._list.__len__()
5✔
642

643
    def add(self, value: Union[str, re.Pattern]):
5✔
644
        if self._immutable:
5!
645
            raise RuntimeError("immutable")
×
646

647
        _list = self._list
5✔
648
        value = self.__wrap__(value)
5✔
649
        if value not in _list:
5!
650
            _list.append(value)
5✔
651

652
    def extend(self, values: Iterable[Union[str, re.Pattern]]):
5✔
653
        for v in values:
×
654
            self.add(v)
×
655

656
    def discard(self, value: Union[str, re.Pattern]):
5✔
657
        if self._immutable:
5!
658
            raise RuntimeError("immutable")
×
659

660
        _list = self._list
5✔
661
        value = self.__wrap__(value)
5✔
662
        if value in _list:
5!
663
            _list.remove(value)
5✔
664

665
    def add_first(self, value: Union[str, re.Pattern]):
5✔
666
        if self._immutable:
×
667
            raise RuntimeError("immutable")
×
668

669
        _list = self._list
×
670
        value = self.__wrap__(value)
×
671
        if value not in _list:
×
672
            _list.insert(0, value)
×
673

674
    def extend_first(self, values: Reversible[Union[str, re.Pattern]]):
5✔
675
        for v in reversed(values):
×
676
            self.add_first(v)
×
677

678
    def __str__(self):
5✔
679
        return self._list.__str__()
5✔
680

681
    def __repr__(self):
5✔
682
        return f"Globs[{self._list}]"
×
683

684

685
class Template:
5✔
686
    def __init__(self, name: str, template: JinjaTemplate, defaults: dict = None, path=None, source=None):
5✔
687
        self.name = name
5✔
688
        self.source = source
5✔
689
        self.path = path
5✔
690
        self.template = template
5✔
691
        self.defaults = defaults
5✔
692

693
    def render(self, context: dict, values: dict):
5✔
694
        variables = {"ktor": context,
5✔
695
                     "values": (self.defaults or {}) | values}
696
        return self.template.render(variables)
5✔
697

698

699
class StringIO:
5✔
700
    def __init__(self, trimmed=True):
5✔
701
        self.write = self.write_trimmed if trimmed else self.write_untrimmed
5✔
702
        self._buf = io_StringIO()
5✔
703

704
    def write_untrimmed(self, line):
5✔
705
        self._buf.write(line)
5✔
706

707
    def write_trimmed(self, line):
5✔
708
        self._buf.write(f"{line}\n")
×
709

710
    def getvalue(self):
5✔
711
        return self._buf.getvalue()
5✔
712

713

714
class StripNL:
5✔
715
    def __init__(self, func):
5✔
716
        self._func = func
5✔
717

718
    def __call__(self, line: str):
5✔
719
        return self._func(line.rstrip("\r\n"))
5✔
720

721

722
def log_level_to_verbosity_count(level: int):
5✔
723
    return int(-level / 10 + 6)
5✔
724

725

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

729

730
def prepend_os_path(path):
5✔
731
    path = str(path)
5✔
732
    paths = os.environ["PATH"].split(os.pathsep)
5✔
733
    if path not in paths:
5!
734
        paths.insert(0, path)
5✔
735
        os.environ["PATH"] = os.pathsep.join(paths)
5✔
736
        return True
5✔
737
    return False
×
738

739

740
_GOLANG_MACHINE = platform.machine().lower()
5✔
741
if _GOLANG_MACHINE == "x86_64":
5!
742
    _GOLANG_MACHINE = "amd64"
5✔
743

744
_GOLANG_OS = platform.system().lower()
5✔
745

746

747
def get_golang_machine():
5✔
748
    return _GOLANG_MACHINE
5✔
749

750

751
def get_golang_os():
5✔
752
    return _GOLANG_OS
5✔
753

754

755
def sha256_file_digest(path):
5✔
756
    h = sha256()
×
757
    with open(path, "rb") as f:
×
758
        h.update(f.read(65535))
×
759
    return h.hexdigest()
×
760

761

762
class Repository:
5✔
763
    logger = logging.getLogger("kubernator.repository")
5✔
764
    git_logger = logger.getChild("git")
5✔
765

766
    def __init__(self, repo, cred_aug=None):
5✔
767
        repo = str(repo)  # in case this is a Path
5✔
768
        url = urllib.parse.urlsplit(repo)
5✔
769

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

773
        self.url = url
5✔
774
        self.url_str = urllib.parse.urlunsplit(url[:4] + ("",))
5✔
775
        self._cred_aug = cred_aug
5✔
776
        self._hash_obj = (url.hostname if url.username or url.password else url.netloc,
5✔
777
                          url.path,
778
                          url.query)
779

780
        self.clone_url = None  # Actual URL components used in cloning operations
5✔
781
        self.clone_url_str = None  # Actual URL string used in cloning operations
5✔
782
        self.ref = None
5✔
783
        self.local_dir = None
5✔
784

785
    def __eq__(self, o: object) -> bool:
5✔
786
        if isinstance(o, Repository):
×
787
            return self._hash_obj == o._hash_obj
×
788

789
    def __hash__(self) -> int:
5✔
790
        return hash(self._hash_obj)
5✔
791

792
    def init(self, logger, context):
5✔
793
        run = context.app.run
5✔
794
        run_capturing_out = context.app.run_capturing_out
5✔
795

796
        url = self.url
5✔
797
        if self._cred_aug:
5!
798
            url = self._cred_aug(url)
5✔
799

800
        self.clone_url = url
5✔
801
        self.clone_url_str = clone_url_str(url)
5✔
802

803
        query = urllib.parse.parse_qs(self.url.query)
5✔
804
        ref = query.get("ref")
5✔
805
        if ref:
5✔
806
            self.ref = ref[0]
5✔
807

808
        config_dir = get_cache_dir("git")
5✔
809

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

812
        if git_cache.exists() and git_cache.is_dir() and (git_cache / ".git").exists():
5✔
813
            try:
5✔
814
                run(["git", "status"], None, None, cwd=git_cache).wait()
5✔
815
            except CalledProcessError:
×
816
                rmtree(git_cache)
×
817

818
        self.local_dir = git_cache
5✔
819

820
        stdout_logger = StripNL(self.git_logger.debug)
5✔
821
        stderr_logger = StripNL(self.git_logger.info)
5✔
822
        if git_cache.exists():
5✔
823
            if not self.ref:
5!
824
                ref = run_capturing_out(["git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
×
825
                                        stderr_logger, cwd=git_cache).strip()[7:]  # Remove prefix "origin/"
826
            else:
827
                ref = self.ref
5✔
828
            self.logger.info("Using %s%s cached in %s", self.url_str,
5✔
829
                             f"?ref={ref}" if not self.ref else "",
830
                             self.local_dir)
831
            run(["git", "config", "remote.origin.fetch", f"+refs/heads/{ref}:refs/remotes/origin/{ref}"],
5✔
832
                stdout_logger, stderr_logger, cwd=git_cache).wait()
833
            run(["git", "fetch", "-pPt", "--force"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
834
            run(["git", "checkout", ref], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
835
            run(["git", "clean", "-f"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
836
            run(["git", "reset", "--hard", ref, "--"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
837
            run(["git", "pull"], stdout_logger, stderr_logger, cwd=git_cache).wait()
5✔
838
        else:
839
            self.logger.info("Initializing %s -> %s", self.url_str, self.local_dir)
5✔
840
            args = (["git", "clone", "--depth", "1",
5✔
841
                     "-" + ("v" * log_level_to_verbosity_count(logger.getEffectiveLevel()))] +
842
                    (["-b", self.ref] if self.ref else []) +
843
                    ["--", self.clone_url_str, str(self.local_dir)])
844
            safe_args = [c if c != self.clone_url_str else self.url_str for c in args]
5✔
845
            run(args, stdout_logger, stderr_logger, safe_args=safe_args).wait()
5✔
846

847
    def cleanup(self):
5✔
848
        if False and self.local_dir:
5✔
849
            self.logger.info("Cleaning up %s -> %s", self.url_str, self.local_dir)
850
            rmtree(self.local_dir)
851

852

853
class KubernatorPlugin:
5✔
854
    _name = None
5✔
855

856
    def set_context(self, context):
5✔
857
        raise NotImplementedError
×
858

859
    def register(self, **kwargs):
5✔
860
        pass
5✔
861

862
    def handle_init(self):
5✔
863
        pass
5✔
864

865
    def handle_start(self):
5✔
866
        pass
5✔
867

868
    def handle_before_dir(self, cwd: Path):
5✔
869
        pass
5✔
870

871
    def handle_before_script(self, cwd: Path):
5✔
872
        pass
5✔
873

874
    def handle_after_script(self, cwd: Path):
5✔
875
        pass
5✔
876

877
    def handle_after_dir(self, cwd: Path):
5✔
878
        pass
5✔
879

880
    def handle_apply(self):
5✔
881
        pass
5✔
882

883
    def handle_verify(self):
5✔
884
        pass
5✔
885

886
    def handle_cleanup(self):
5✔
887
        pass
5✔
888

889
    def handle_shutdown(self):
5✔
890
        pass
5✔
891

892
    def handle_summary(self):
5✔
893
        pass
5✔
894

895

896
def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_stderr, disable_patching,
5✔
897
                              fallback=False):
898
    cache_dir = get_cache_dir("python")
5✔
899
    package_major_dir = cache_dir / str(package_major)
5✔
900
    package_major_dir_str = str(package_major_dir)
5✔
901
    patch_indicator = package_major_dir / ".patched"
5✔
902

903
    if disable_patching and package_major_dir.exists() and patch_indicator.exists():
5✔
904
        logger.info("Patching is disabled, existing Kubernetes Client %s (%s) was patched - "
5✔
905
                    "deleting current client",
906
                    str(package_major), package_major_dir)
907
        rmtree(package_major_dir)
5✔
908

909
    if not package_major_dir.exists() or not len(os.listdir(package_major_dir)):
5✔
910
        package_major_dir.mkdir(parents=True, exist_ok=True)
5✔
911
        try:
5✔
912
            run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input",
5✔
913
                 "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
914
                 "--target", package_major_dir_str, f"kubernetes>={package_major!s}dev0,<{int(package_major) + 1!s}"],
915
                logger_stdout, logger_stderr)
916
        except CalledProcessError as e:
×
917
            if not fallback and "No matching distribution found for" in e.stderr:
×
918
                logger.warning("Kubernetes Client %s (%s) failed to install because the version wasn't found. "
×
919
                               "Falling back to a client of the previous version - %s",
920
                               str(package_major), package_major_dir, int(package_major) - 1)
921
                return install_python_k8s_client(run,
×
922
                                                 int(package_major) - 1,
923
                                                 logger,
924
                                                 logger_stdout,
925
                                                 logger_stderr,
926
                                                 disable_patching,
927
                                                 fallback=True)
928
            else:
929
                raise
×
930

931
    if not patch_indicator.exists() and not disable_patching:
5✔
932
        if not fallback and not len(os.listdir(package_major_dir)):
5!
933
            # Directory is empty
934
            logger.warning("Kubernetes Client %s (%s) directory is empty - the client was not installed. "
×
935
                           "Falling back to a client of the previous version - %s",
936
                           str(package_major), package_major_dir, int(package_major) - 1)
937

938
            return install_python_k8s_client(run,
×
939
                                             int(package_major) - 1,
940
                                             logger,
941
                                             logger_stdout,
942
                                             logger_stderr,
943
                                             disable_patching,
944
                                             fallback=True)
945

946
        for patch_text, target_file, skip_if_found, min_version, max_version, name in (
5✔
947
                URLLIB_HEADERS_PATCH, CUSTOM_OBJECT_PATCH_25):
948
            patch_target = package_major_dir / target_file
5✔
949
            logger.info("Applying patch %s to %s...", name, patch_target)
5✔
950
            if min_version and int(package_major) < min_version:
5!
951
                logger.info("Skipping patch %s on %s due to package major version %s below minimum %d!",
×
952
                            name, patch_target, package_major, min_version)
953
                continue
×
954
            if max_version and int(package_major) > max_version:
5!
955
                logger.info("Skipping patch %s on %s due to package major version %s above maximum %d!",
×
956
                            name, patch_target, package_major, max_version)
957
                continue
×
958

959
            with open(patch_target, "rt") as f:
5✔
960
                target_file_original = f.read()
5✔
961
            if skip_if_found in target_file_original:
5✔
962
                logger.info("Skipping patch %s on %s, as it already appears to be patched!", name,
5✔
963
                            patch_target)
964
                continue
5✔
965

966
            dmp = diff_match_patch()
5✔
967
            patches = dmp.patch_fromText(patch_text)
5✔
968
            target_file_patched, results = dmp.patch_apply(patches, target_file_original)
5✔
969
            failed_patch = False
5✔
970
            for idx, result in enumerate(results):
5✔
971
                if not result:
5!
972
                    failed_patch = True
×
973
                    msg = ("Failed to apply a patch to Kubernetes Client API %s, hunk #%d, patch: \n%s" % (
×
974
                        patch_target, idx, patches[idx]))
975
                    logger.fatal(msg)
×
976
            if failed_patch:
5!
977
                raise RuntimeError(f"Failed to apply some Kubernetes Client API {patch_target} patches")
×
978

979
            with open(patch_target, "wt") as f:
5✔
980
                f.write(target_file_patched)
5✔
981

982
        patch_indicator.touch(exist_ok=False)
5✔
983

984
    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