kubernator 1.0.16__py3-none-any.whl → 1.0.24.dev20251109010128__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kubernator/__init__.py CHANGED
@@ -16,7 +16,7 @@
16
16
  # limitations under the License.
17
17
  #
18
18
 
19
- __version__ = "1.0.16"
19
+ __version__ = "1.0.24.dev20251109010128"
20
20
 
21
21
 
22
22
  def _main():
kubernator/api.py CHANGED
@@ -23,6 +23,7 @@ import os
23
23
  import platform
24
24
  import re
25
25
  import sys
26
+ import textwrap
26
27
  import traceback
27
28
  import urllib.parse
28
29
  from collections.abc import Callable
@@ -38,14 +39,16 @@ from typing import Optional, Union, MutableSequence
38
39
 
39
40
  import requests
40
41
  import yaml
41
- from platformdirs import user_cache_dir
42
42
  from diff_match_patch import diff_match_patch
43
+ from gevent import sleep
43
44
  from jinja2 import (Environment,
44
45
  ChainableUndefined,
45
46
  make_logging_undefined,
46
47
  Template as JinjaTemplate,
47
48
  pass_context)
48
49
  from jsonschema import validators
50
+ from platformdirs import user_cache_dir
51
+ from yaml import MarkedYAMLError
49
52
 
50
53
  from kubernator._json_path import jp # noqa: F401
51
54
  from kubernator._k8s_client_patches import (URLLIB_HEADERS_PATCH,
@@ -57,6 +60,98 @@ _CACHE_HEADER_TRANSLATION = {"etag": "if-none-match",
57
60
  _CACHE_HEADERS = ("etag", "last-modified")
58
61
 
59
62
 
63
+ def to_json(obj: Union[dict, list]):
64
+ return json.dumps(obj)
65
+
66
+
67
+ def to_yaml_str(s: str):
68
+ return yaml.safe_dump(s)
69
+
70
+
71
+ def to_json_yaml_str(obj: Union[dict, list]):
72
+ """
73
+ Takes `obj`, dumps as json representation, converts json representation to YAML string literal.
74
+ """
75
+ return to_yaml_str(to_json(obj))
76
+
77
+
78
+ def to_yaml_str_block(s: str, indent: int = 4, pretty_indent: int = 2):
79
+ """
80
+ Takes a multiline string, dedents it then indents it `indent` spaces for in-yaml alignment and
81
+ `pretty-indent` spaces for in-block alignment.
82
+ """
83
+ return (f"|+{pretty_indent}\n" +
84
+ textwrap.indent(textwrap.dedent(s), " " * (indent + pretty_indent)))
85
+
86
+
87
+ def to_json_yaml_str_block(obj: Union[str, dict, list], indent: int = 4, pretty_indent=2):
88
+ """
89
+ Takes an `obj`, serializes it as pretty JSON with `pretty_indent` in-json indentation and then
90
+ passes it to `to_yaml_str_block`.
91
+ """
92
+ return to_yaml_str_block(json.dumps(obj, indent=pretty_indent), indent=indent, pretty_indent=pretty_indent)
93
+
94
+
95
+ def to_yaml(obj: Union[dict, list], level_indent: int, indent: int):
96
+ s = yaml.safe_dump(obj, indent=indent)
97
+ return "\n" + textwrap.indent(s, " " * level_indent)
98
+
99
+
100
+ class TemplateEngine:
101
+ VARIABLE_START_STRING = "{${"
102
+ VARIABLE_END_STRING = "}$}"
103
+
104
+ def __init__(self, logger):
105
+ self.template_failures = 0
106
+ self.templates = {}
107
+
108
+ class CollectingUndefined(ChainableUndefined):
109
+ __slots__ = ()
110
+
111
+ def __str__(self):
112
+ self.template_failures += 1
113
+ return super().__str__()
114
+
115
+ logging_undefined = make_logging_undefined(
116
+ logger=logger,
117
+ base=CollectingUndefined
118
+ )
119
+
120
+ @pass_context
121
+ def variable_finalizer(ctx, value):
122
+ normalized_value = str(value)
123
+ if self.VARIABLE_START_STRING in normalized_value and self.VARIABLE_END_STRING in normalized_value:
124
+ value_template_content = sys.intern(normalized_value)
125
+ env: Environment = ctx.environment
126
+ value_template = self.templates.get(value_template_content)
127
+ if not value_template:
128
+ value_template = env.from_string(value_template_content, env.globals)
129
+ self.templates[value_template_content] = value_template
130
+ return value_template.render(ctx.parent)
131
+
132
+ return normalized_value
133
+
134
+ self.env = Environment(variable_start_string=self.VARIABLE_START_STRING,
135
+ variable_end_string=self.VARIABLE_END_STRING,
136
+ autoescape=False,
137
+ finalize=variable_finalizer,
138
+ undefined=logging_undefined,
139
+ )
140
+
141
+ self.env.filters["to_json"] = to_json
142
+ self.env.filters["to_yaml_str"] = to_yaml_str
143
+ self.env.filters["to_yaml"] = to_yaml
144
+ self.env.filters["to_yaml_str_block"] = to_yaml_str_block
145
+ self.env.filters["to_json_yaml_str_block"] = to_json_yaml_str_block
146
+ self.env.filters["to_json_yaml_str"] = to_json_yaml_str
147
+
148
+ def from_string(self, template):
149
+ return self.env.from_string(template)
150
+
151
+ def failures(self):
152
+ return self.template_failures
153
+
154
+
60
155
  def calling_frame_source(depth=2):
61
156
  f = traceback.extract_stack(limit=depth + 1)[0]
62
157
  return f"file {f.filename}, line {f.lineno} in {f.name}"
@@ -87,18 +182,33 @@ def scan_dir(logger, path: Path, path_filter: Callable[[os.DirEntry], bool], exc
87
182
  yield path / f
88
183
 
89
184
 
185
+ def parse_yaml_docs(document: str, source=None):
186
+ try:
187
+ return list(d for d in yaml.safe_load_all(document) if d)
188
+ except MarkedYAMLError:
189
+ raise
190
+
191
+
90
192
  class FileType(Enum):
91
- JSON = (json.load,)
92
- YAML = (yaml.safe_load_all,)
193
+ TEXT = (lambda x: x,)
194
+ BINARY = (lambda x: x,)
195
+ JSON = (json.loads,)
196
+ YAML = (parse_yaml_docs,)
93
197
 
94
198
  def __init__(self, func):
95
199
  self.func = func
96
200
 
97
201
 
98
- def _load_file(logger, path: Path, file_type: FileType, source=None) -> Iterable[dict]:
99
- with open(path, "rb") as f:
202
+ def _load_file(logger, path: Path, file_type: FileType, source=None,
203
+ template_engine: Optional[TemplateEngine] = None,
204
+ template_context: Optional[dict] = None) -> Iterable[dict]:
205
+ with open(path, "rb" if file_type == FileType.BINARY else "rt") as f:
100
206
  try:
101
- data = file_type.func(f)
207
+ if template_engine and not file_type == FileType.BINARY:
208
+ raw_data = template_engine.from_string(f.read()).render(template_context)
209
+ else:
210
+ raw_data = f.read()
211
+ data = file_type.func(raw_data)
102
212
  if isinstance(data, GeneratorType):
103
213
  data = list(data)
104
214
  return data
@@ -108,13 +218,29 @@ def _load_file(logger, path: Path, file_type: FileType, source=None) -> Iterable
108
218
 
109
219
 
110
220
  def _download_remote_file(url, file_name, cache: dict):
111
- with requests.get(url, headers=cache, stream=True) as r:
112
- r.raise_for_status()
113
- if r.status_code != 304:
114
- with open(file_name, "wb") as out:
115
- for chunk in r.iter_content(chunk_size=65535):
116
- out.write(chunk)
117
- return dict(r.headers)
221
+ retry_delay = 0
222
+ while True:
223
+ if retry_delay:
224
+ sleep(retry_delay)
225
+
226
+ with requests.get(url, headers=cache, stream=True) as r:
227
+ if r.status_code == 429:
228
+ if not retry_delay:
229
+ retry_delay = 0.2
230
+ else:
231
+ retry_delay *= 2.0
232
+ if retry_delay > 2.5:
233
+ retry_delay = 2.5
234
+ continue
235
+
236
+ r.raise_for_status()
237
+ if r.status_code != 304:
238
+ with open(file_name, "wb") as out:
239
+ for chunk in r.iter_content(chunk_size=65535):
240
+ out.write(chunk)
241
+ return dict(r.headers)
242
+ else:
243
+ return None
118
244
 
119
245
 
120
246
  def get_app_cache_dir():
@@ -174,9 +300,12 @@ def load_remote_file(logger, url, file_type: FileType, category: str = "k8s", su
174
300
  return _load_file(logger, file_name, file_type, url)
175
301
 
176
302
 
177
- def load_file(logger, path: Path, file_type: FileType, source=None) -> Iterable[dict]:
303
+ def load_file(logger, path: Path, file_type: FileType, source=None,
304
+ template_engine: Optional[TemplateEngine] = None,
305
+ template_context: Optional[dict] = None) -> Iterable[dict]:
178
306
  logger.debug("Loading %s using %s", source or path, file_type.name)
179
- return _load_file(logger, path, file_type)
307
+ return _load_file(logger, path, file_type,
308
+ source, template_engine, template_context)
180
309
 
181
310
 
182
311
  def validator_with_defaults(validator_class):
@@ -496,53 +625,6 @@ class Globs(MutableSet[Union[str, re.Pattern]]):
496
625
  return f"Globs[{self._list}]"
497
626
 
498
627
 
499
- class TemplateEngine:
500
- VARIABLE_START_STRING = "{${"
501
- VARIABLE_END_STRING = "}$}"
502
-
503
- def __init__(self, logger):
504
- self.template_failures = 0
505
- self.templates = {}
506
-
507
- class CollectingUndefined(ChainableUndefined):
508
- __slots__ = ()
509
-
510
- def __str__(self):
511
- self.template_failures += 1
512
- return super().__str__()
513
-
514
- logging_undefined = make_logging_undefined(
515
- logger=logger,
516
- base=CollectingUndefined
517
- )
518
-
519
- @pass_context
520
- def variable_finalizer(ctx, value):
521
- normalized_value = str(value)
522
- if self.VARIABLE_START_STRING in normalized_value and self.VARIABLE_END_STRING in normalized_value:
523
- value_template_content = sys.intern(normalized_value)
524
- env: Environment = ctx.environment
525
- value_template = self.templates.get(value_template_content)
526
- if not value_template:
527
- value_template = env.from_string(value_template_content, env.globals)
528
- self.templates[value_template_content] = value_template
529
- return value_template.render(ctx.parent)
530
-
531
- return normalized_value
532
-
533
- self.env = Environment(variable_start_string=self.VARIABLE_START_STRING,
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):
540
- return self.env.from_string(template)
541
-
542
- def failures(self):
543
- return self.template_failures
544
-
545
-
546
628
  class Template:
547
629
  def __init__(self, name: str, template: JinjaTemplate, defaults: dict = None, path=None, source=None):
548
630
  self.name = name
@@ -751,7 +833,8 @@ class KubernatorPlugin:
751
833
  pass
752
834
 
753
835
 
754
- def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_stderr, disable_patching):
836
+ def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_stderr, disable_patching,
837
+ fallback=False):
755
838
  cache_dir = get_cache_dir("python")
756
839
  package_major_dir = cache_dir / str(package_major)
757
840
  package_major_dir_str = str(package_major_dir)
@@ -763,13 +846,43 @@ def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_
763
846
  str(package_major), package_major_dir)
764
847
  rmtree(package_major_dir)
765
848
 
766
- if not package_major_dir.exists():
849
+ if not package_major_dir.exists() or not len(os.listdir(package_major_dir)):
767
850
  package_major_dir.mkdir(parents=True, exist_ok=True)
768
- run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input", "--pre",
769
- "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
770
- "--target", package_major_dir_str, f"kubernetes~={package_major}.0"], logger_stdout, logger_stderr).wait()
851
+ try:
852
+ run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input",
853
+ "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
854
+ "--target", package_major_dir_str, f"kubernetes>={package_major!s}dev0,<{int(package_major) + 1!s}"],
855
+ logger_stdout, logger_stderr)
856
+ except CalledProcessError as e:
857
+ if not fallback and "No matching distribution found for" in e.stderr:
858
+ logger.warning("Kubernetes Client %s (%s) failed to install because the version wasn't found. "
859
+ "Falling back to a client of the previous version - %s",
860
+ str(package_major), package_major_dir, int(package_major) - 1)
861
+ return install_python_k8s_client(run,
862
+ int(package_major) - 1,
863
+ logger,
864
+ logger_stdout,
865
+ logger_stderr,
866
+ disable_patching,
867
+ fallback=True)
868
+ else:
869
+ raise
771
870
 
772
871
  if not patch_indicator.exists() and not disable_patching:
872
+ if not fallback and not len(os.listdir(package_major_dir)):
873
+ # Directory is empty
874
+ logger.warning("Kubernetes Client %s (%s) directory is empty - the client was not installed. "
875
+ "Falling back to a client of the previous version - %s",
876
+ str(package_major), package_major_dir, int(package_major) - 1)
877
+
878
+ return install_python_k8s_client(run,
879
+ int(package_major) - 1,
880
+ logger,
881
+ logger_stdout,
882
+ logger_stderr,
883
+ disable_patching,
884
+ fallback=True)
885
+
773
886
  for patch_text, target_file, skip_if_found, min_version, max_version, name in (
774
887
  URLLIB_HEADERS_PATCH, CUSTOM_OBJECT_PATCH_23, CUSTOM_OBJECT_PATCH_25):
775
888
  patch_target = package_major_dir / target_file
kubernator/app.py CHANGED
@@ -35,7 +35,7 @@ import kubernator
35
35
  from kubernator.api import (KubernatorPlugin, Globs, scan_dir, PropertyDict, config_as_dict, config_parent,
36
36
  download_remote_file, load_remote_file, Repository, StripNL, jp, get_app_cache_dir,
37
37
  get_cache_dir, install_python_k8s_client)
38
- from kubernator.proc import run, run_capturing_out
38
+ from kubernator.proc import run, run_capturing_out, run_pass_through_capturing
39
39
 
40
40
  TRACE = 5
41
41
 
@@ -215,7 +215,7 @@ class App(KubernatorPlugin):
215
215
  self.context = self._top_dir_context
216
216
  context = self.context
217
217
  self._run_handlers(KubernatorPlugin.handle_shutdown, True, context, None)
218
- except: # noqa E722
218
+ except: # noqa E722
219
219
  raise
220
220
  else:
221
221
  self.context = self._top_dir_context
@@ -348,6 +348,7 @@ class App(KubernatorPlugin):
348
348
  jp=jp,
349
349
  run=self._run,
350
350
  run_capturing_out=self._run_capturing_out,
351
+ run_passthrough_capturing=self._run_passthrough_capturing,
351
352
  repository=self.repository,
352
353
  StripNL=StripNL,
353
354
  default_includes=Globs(["*"], True),
@@ -465,6 +466,9 @@ class App(KubernatorPlugin):
465
466
  def _run_capturing_out(self, *args, **kwargs):
466
467
  return run_capturing_out(*args, **kwargs)
467
468
 
469
+ def _run_passthrough_capturing(self, *args, **kwargs):
470
+ return run_pass_through_capturing(*args, **kwargs)
471
+
468
472
  def __repr__(self):
469
473
  return "Kubernator"
470
474
 
@@ -493,7 +497,7 @@ def pre_cache_k8s_clients(*versions, disable_patching=False):
493
497
  for v in versions:
494
498
  logger.info("Caching K8S client library ~=v%s.0%s...", v,
495
499
  " (no patches)" if disable_patching else "")
496
- install_python_k8s_client(run, v, logger, stdout_logger, stderr_logger, disable_patching)
500
+ install_python_k8s_client(run_pass_through_capturing, v, logger, stdout_logger, stderr_logger, disable_patching)
497
501
 
498
502
 
499
503
  def main():
@@ -0,0 +1,105 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2020 Express Systems USA, Inc
4
+ # Copyright 2025 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 logging
20
+ import os
21
+ import tempfile
22
+ from pathlib import Path
23
+ from shutil import which
24
+
25
+ from kubernator.api import (KubernatorPlugin,
26
+ StripNL
27
+ )
28
+
29
+ logger = logging.getLogger("kubernator.gke")
30
+ proc_logger = logger.getChild("proc")
31
+ stdout_logger = StripNL(proc_logger.info)
32
+ stderr_logger = StripNL(proc_logger.warning)
33
+
34
+
35
+ class GkePlugin(KubernatorPlugin):
36
+ logger = logger
37
+
38
+ _name = "gke"
39
+
40
+ def __init__(self):
41
+ self.context = None
42
+ self.kubeconfig_dir = tempfile.TemporaryDirectory()
43
+ self.name = None
44
+ self.region = None
45
+ self.project = None
46
+ self.gcloud_file = None
47
+
48
+ super().__init__()
49
+
50
+ def set_context(self, context):
51
+ self.context = context
52
+
53
+ def register(self, *, name=None, region=None, project=None):
54
+ context = self.context
55
+
56
+ if not name:
57
+ raise ValueError("`name` is required")
58
+ if not region:
59
+ raise ValueError("`region` is required")
60
+ if not project:
61
+ raise ValueError("`project` is required")
62
+
63
+ self.name = name
64
+ self.region = region
65
+ self.project = project
66
+
67
+ # Use current version
68
+ gcloud_file = which("gcloud")
69
+ if not gcloud_file:
70
+ raise RuntimeError("`gcloud` cannot be found")
71
+ logger.debug("Found gcloud in %r", gcloud_file)
72
+ self.gcloud_file = gcloud_file
73
+
74
+ context.app.register_plugin("kubeconfig")
75
+
76
+ context.globals.gke = dict(kubeconfig=str(Path(self.kubeconfig_dir.name) / "config"),
77
+ name=name,
78
+ region=region,
79
+ project=project,
80
+ gcloud_file=gcloud_file
81
+ )
82
+
83
+ def handle_init(self):
84
+ context = self.context
85
+
86
+ env = dict(os.environ)
87
+ env["KUBECONFIG"] = str(context.gke.kubeconfig)
88
+ self.context.app.run([context.gke.gcloud_file, "components", "install", "gke-gcloud-auth-plugin"],
89
+ stdout_logger,
90
+ stderr_logger).wait()
91
+ self.context.app.run([context.gke.gcloud_file, "container", "clusters", "get-credentials",
92
+ context.gke.name,
93
+ "--region", context.gke.region,
94
+ "--project", context.gke.project],
95
+ stdout_logger,
96
+ stderr_logger,
97
+ env=env).wait()
98
+
99
+ context.kubeconfig.set(context.gke.kubeconfig)
100
+
101
+ def handle_shutdown(self):
102
+ self.kubeconfig_dir.cleanup()
103
+
104
+ def __repr__(self):
105
+ return "GKE Plugin"