kubernator 1.0.14__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.14"
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 appdirs import user_config_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,27 +218,43 @@ 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():
121
- return Path(user_config_dir("kubernator"))
247
+ return Path(user_cache_dir("kubernator", "karellen"))
122
248
 
123
249
 
124
250
  def get_cache_dir(category: str, sub_category: str = None):
125
- config_dir = get_app_cache_dir() / category
251
+ cache_dir = get_app_cache_dir() / category
126
252
  if sub_category:
127
- config_dir = config_dir / sub_category
128
- if not config_dir.exists():
129
- config_dir.mkdir(parents=True)
253
+ cache_dir = cache_dir / sub_category
254
+ if not cache_dir.exists():
255
+ cache_dir.mkdir(parents=True)
130
256
 
131
- return config_dir
257
+ return cache_dir
132
258
 
133
259
 
134
260
  def download_remote_file(logger, url: str, category: str = "k8s", sub_category: str = None,
@@ -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
@@ -747,8 +829,12 @@ class KubernatorPlugin:
747
829
  def handle_shutdown(self):
748
830
  pass
749
831
 
832
+ def handle_summary(self):
833
+ pass
834
+
750
835
 
751
- 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):
752
838
  cache_dir = get_cache_dir("python")
753
839
  package_major_dir = cache_dir / str(package_major)
754
840
  package_major_dir_str = str(package_major_dir)
@@ -760,13 +846,43 @@ def install_python_k8s_client(run, package_major, logger, logger_stdout, logger_
760
846
  str(package_major), package_major_dir)
761
847
  rmtree(package_major_dir)
762
848
 
763
- if not package_major_dir.exists():
849
+ if not package_major_dir.exists() or not len(os.listdir(package_major_dir)):
764
850
  package_major_dir.mkdir(parents=True, exist_ok=True)
765
- run([sys.executable, "-m", "pip", "install", "--no-deps", "--no-input", "--pre",
766
- "--root-user-action=ignore", "--break-system-packages", "--disable-pip-version-check",
767
- "--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
768
870
 
769
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
+
770
886
  for patch_text, target_file, skip_if_found, min_version, max_version, name in (
771
887
  URLLIB_HEADERS_PATCH, CUSTOM_OBJECT_PATCH_23, CUSTOM_OBJECT_PATCH_25):
772
888
  patch_target = package_major_dir / target_file
kubernator/app.py CHANGED
@@ -29,11 +29,13 @@ from pathlib import Path
29
29
  from shutil import rmtree
30
30
  from typing import Optional, Union
31
31
 
32
+ import yaml
33
+
32
34
  import kubernator
33
35
  from kubernator.api import (KubernatorPlugin, Globs, scan_dir, PropertyDict, config_as_dict, config_parent,
34
36
  download_remote_file, load_remote_file, Repository, StripNL, jp, get_app_cache_dir,
35
37
  get_cache_dir, install_python_k8s_client)
36
- from kubernator.proc import run, run_capturing_out
38
+ from kubernator.proc import run, run_capturing_out, run_pass_through_capturing
37
39
 
38
40
  TRACE = 5
39
41
 
@@ -55,6 +57,11 @@ logging.addLevelName(5, "TRACE")
55
57
  logging.Logger.trace = trace
56
58
  logger = logging.getLogger("kubernator")
57
59
 
60
+ try:
61
+ del (yaml.resolver.Resolver.yaml_implicit_resolvers["="])
62
+ except KeyError:
63
+ pass
64
+
58
65
 
59
66
  def define_arg_parse():
60
67
  parser = argparse.ArgumentParser(prog="kubernator",
@@ -171,42 +178,49 @@ class App(KubernatorPlugin):
171
178
  self.register_plugin(self)
172
179
 
173
180
  try:
174
- while True:
175
- cwd = self.next()
176
- if not cwd:
177
- logger.debug("No paths left to traverse")
178
- break
181
+ try:
182
+ while True:
183
+ cwd = self.next()
184
+ if not cwd:
185
+ logger.debug("No paths left to traverse")
186
+ break
179
187
 
180
- context = self.context
188
+ context = self.context
181
189
 
182
- logger.debug("Inspecting directory %s", self._display_path(cwd))
183
- self._run_handlers(KubernatorPlugin.handle_before_dir, False, context, None, cwd)
190
+ logger.debug("Inspecting directory %s", self._display_path(cwd))
191
+ self._run_handlers(KubernatorPlugin.handle_before_dir, False, context, None, cwd)
184
192
 
185
- if (ktor_py := (cwd / ".kubernator.py")).exists():
186
- self._run_handlers(KubernatorPlugin.handle_before_script, False, context, None, cwd)
193
+ if (ktor_py := (cwd / ".kubernator.py")).exists():
194
+ self._run_handlers(KubernatorPlugin.handle_before_script, False, context, None, cwd)
187
195
 
188
- for h in self.context._plugins:
189
- h.set_context(context)
196
+ for h in self.context._plugins:
197
+ h.set_context(context)
190
198
 
191
- self._exec_ktor(ktor_py)
199
+ self._exec_ktor(ktor_py)
192
200
 
193
- for h in self.context._plugins:
194
- h.set_context(None)
201
+ for h in self.context._plugins:
202
+ h.set_context(None)
195
203
 
196
- self._run_handlers(KubernatorPlugin.handle_after_script, True, context, None, cwd)
204
+ self._run_handlers(KubernatorPlugin.handle_after_script, True, context, None, cwd)
197
205
 
198
- self._run_handlers(KubernatorPlugin.handle_after_dir, True, context, None, cwd)
206
+ self._run_handlers(KubernatorPlugin.handle_after_dir, True, context, None, cwd)
199
207
 
200
- self.context = self._top_dir_context
201
- context = self.context
208
+ self.context = self._top_dir_context
209
+ context = self.context
202
210
 
203
- self._run_handlers(KubernatorPlugin.handle_apply, True, context, None)
211
+ self._run_handlers(KubernatorPlugin.handle_apply, True, context, None)
204
212
 
205
- self._run_handlers(KubernatorPlugin.handle_verify, True, context, None)
206
- finally:
213
+ self._run_handlers(KubernatorPlugin.handle_verify, True, context, None)
214
+ finally:
215
+ self.context = self._top_dir_context
216
+ context = self.context
217
+ self._run_handlers(KubernatorPlugin.handle_shutdown, True, context, None)
218
+ except: # noqa E722
219
+ raise
220
+ else:
207
221
  self.context = self._top_dir_context
208
222
  context = self.context
209
- self._run_handlers(KubernatorPlugin.handle_shutdown, True, context, None)
223
+ self._run_handlers(KubernatorPlugin.handle_summary, True, context, None)
210
224
 
211
225
  def discover_plugins(self):
212
226
  importlib.invalidate_caches()
@@ -334,6 +348,7 @@ class App(KubernatorPlugin):
334
348
  jp=jp,
335
349
  run=self._run,
336
350
  run_capturing_out=self._run_capturing_out,
351
+ run_passthrough_capturing=self._run_passthrough_capturing,
337
352
  repository=self.repository,
338
353
  StripNL=StripNL,
339
354
  default_includes=Globs(["*"], True),
@@ -451,6 +466,9 @@ class App(KubernatorPlugin):
451
466
  def _run_capturing_out(self, *args, **kwargs):
452
467
  return run_capturing_out(*args, **kwargs)
453
468
 
469
+ def _run_passthrough_capturing(self, *args, **kwargs):
470
+ return run_pass_through_capturing(*args, **kwargs)
471
+
454
472
  def __repr__(self):
455
473
  return "Kubernator"
456
474
 
@@ -479,7 +497,7 @@ def pre_cache_k8s_clients(*versions, disable_patching=False):
479
497
  for v in versions:
480
498
  logger.info("Caching K8S client library ~=v%s.0%s...", v,
481
499
  " (no patches)" if disable_patching else "")
482
- 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)
483
501
 
484
502
 
485
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"