pysae-cli-tools 0.1.2__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.
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Pysae shared utilities for Python CLIs."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compute_version(build: bool = False) -> str:
|
|
11
|
+
"""Compute the current version from git describe.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
build: If True, return a clean version without .dev suffix (for CI publishing).
|
|
15
|
+
If False (default), append .dev+g<sha> for non-tagged commits.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Version string like "0.2.17" (build) or "0.2.17.dev+gabcdef" (dev).
|
|
19
|
+
"""
|
|
20
|
+
pkg_dir = Path(__file__).resolve().parent
|
|
21
|
+
cwd = str(pkg_dir)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
desc = subprocess.run(
|
|
25
|
+
["git", "describe", "--tags", "--long", "--match", "v*"],
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
encoding="utf-8",
|
|
29
|
+
errors="replace",
|
|
30
|
+
timeout=5,
|
|
31
|
+
cwd=cwd,
|
|
32
|
+
)
|
|
33
|
+
if desc.returncode == 0 and desc.stdout.strip():
|
|
34
|
+
m = re.match(r"^v?(\d+\.\d+\.\d+)-(\d+)-g([0-9a-f]+)$", desc.stdout.strip())
|
|
35
|
+
if m:
|
|
36
|
+
base, dist, sha = m.group(1), int(m.group(2)), m.group(3)
|
|
37
|
+
major, minor, patch = base.split(".")
|
|
38
|
+
if dist == 0:
|
|
39
|
+
return base
|
|
40
|
+
ver = f"{major}.{minor}.{int(patch) + dist}"
|
|
41
|
+
return ver if build else f"{ver}.dev+g{sha}"
|
|
42
|
+
|
|
43
|
+
count = subprocess.run(
|
|
44
|
+
["git", "rev-list", "--count", "HEAD"],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
errors="replace",
|
|
49
|
+
timeout=5,
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
)
|
|
52
|
+
sha_short = subprocess.run(
|
|
53
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
errors="replace",
|
|
58
|
+
timeout=5,
|
|
59
|
+
cwd=cwd,
|
|
60
|
+
)
|
|
61
|
+
c = count.stdout.strip() if count.returncode == 0 else "0"
|
|
62
|
+
s = sha_short.stdout.strip() if sha_short.returncode == 0 else ""
|
|
63
|
+
ver = f"0.1.{c}"
|
|
64
|
+
if build or not s:
|
|
65
|
+
return ver
|
|
66
|
+
return f"{ver}.dev+g{s}"
|
|
67
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
68
|
+
return "0.1.0" if build else "0.1.0.dev"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
__version__ = _pkg_version("pysae-cli-tools")
|
|
73
|
+
if __version__ == "0.1.0":
|
|
74
|
+
__version__ = compute_version(build=False)
|
|
75
|
+
except PackageNotFoundError:
|
|
76
|
+
__version__ = compute_version(build=False)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from pysae_cli_tools.k8s.config import K8sConfig as K8sConfig
|
|
2
|
+
from pysae_cli_tools.k8s.config import K8sEnvironment as K8sEnvironment
|
|
3
|
+
from pysae_cli_tools.k8s.runner import build_k8s_support as build_k8s_support
|
|
4
|
+
from pysae_cli_tools.k8s.runner import k8s_support as k8s_support
|
|
5
|
+
from pysae_cli_tools.k8s.runner import run_on_k8s as run_on_k8s
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Configuration object for the ``@k8s_support`` decorator.
|
|
2
|
+
|
|
3
|
+
Each project consuming :mod:`pysae_cli_tools.k8s` declares a single
|
|
4
|
+
:class:`K8sConfig` describing its image, source layout and secret-
|
|
5
|
+
handling conventions, then either:
|
|
6
|
+
|
|
7
|
+
- passes it explicitly: ``@k8s_support(config=CONFIG)``, or
|
|
8
|
+
- pre-binds it once with :func:`build_k8s_support` and uses the
|
|
9
|
+
returned decorator everywhere: ``@k8s_support()``.
|
|
10
|
+
|
|
11
|
+
The defaults match the Pysae convention (``dev``/``prod`` envs whose
|
|
12
|
+
value doubles as the kubectl context and namespace). Pass a custom
|
|
13
|
+
``StrEnum`` to :attr:`K8sConfig.environments` to model a different
|
|
14
|
+
layout.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import enum
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class K8sEnvironment(enum.StrEnum):
|
|
23
|
+
"""Default Pysae Kubernetes environments. Value == context == namespace."""
|
|
24
|
+
|
|
25
|
+
dev = "dev"
|
|
26
|
+
prod = "prod"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class K8sConfig:
|
|
31
|
+
"""Project-level configuration for the k8s dispatch decorator."""
|
|
32
|
+
|
|
33
|
+
default_image: str
|
|
34
|
+
"""Container image used by default to spawn the ephemeral pod."""
|
|
35
|
+
|
|
36
|
+
project_root: Path
|
|
37
|
+
"""Absolute path to the project root. ``copy_paths`` are resolved relative to it."""
|
|
38
|
+
|
|
39
|
+
copy_paths: tuple[str, ...] = ()
|
|
40
|
+
"""Paths under ``project_root`` to copy into the pod when
|
|
41
|
+
``--k8s-from-local-sources`` is set. Empty disables that mode."""
|
|
42
|
+
|
|
43
|
+
install_script: str | None = None
|
|
44
|
+
"""Bash script to run inside the pod after copying sources
|
|
45
|
+
(``--k8s-from-local-sources`` mode). Typically installs Poetry, the
|
|
46
|
+
project, and any extra binaries the deployed image already carries.
|
|
47
|
+
When ``None``, no bootstrap is performed (the base image must be
|
|
48
|
+
self-contained)."""
|
|
49
|
+
|
|
50
|
+
forwarded_envvars: tuple[str, ...] = ()
|
|
51
|
+
"""Envvar names whose value is propagated from the operator's shell
|
|
52
|
+
into the pod's ``env`` block (treated as sensitive — redacted in logs)."""
|
|
53
|
+
|
|
54
|
+
redacted_options: tuple[str, ...] = ()
|
|
55
|
+
"""CLI option names whose value is masked in the ``[K8S] Running:`` log line."""
|
|
56
|
+
|
|
57
|
+
environments: type[enum.Enum] = field(default=K8sEnvironment)
|
|
58
|
+
"""Enum of supported environments. Each member's value is used as
|
|
59
|
+
both the kubectl context and the namespace."""
|
|
60
|
+
|
|
61
|
+
default_environment_value: str = "dev"
|
|
62
|
+
"""Default value used for ``--k8s-environment`` when the operator
|
|
63
|
+
omits the flag. Must match one of ``environments``' values."""
|
|
64
|
+
|
|
65
|
+
default_pod_name_prefix: str = "pysae-cli"
|
|
66
|
+
"""Pod name prefix. Override per-command via ``@k8s_support(pod_name_prefix=...)``."""
|
|
67
|
+
|
|
68
|
+
dockerfile_path: Path | None = None
|
|
69
|
+
"""Path to the Dockerfile used to extract the base image in
|
|
70
|
+
``--k8s-from-local-sources`` mode. Defaults to ``project_root / "Dockerfile"``."""
|
|
71
|
+
|
|
72
|
+
workdir: str = "/app"
|
|
73
|
+
"""Working directory inside the pod. Sources are extracted here in
|
|
74
|
+
``--k8s-from-local-sources`` mode."""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def resolved_dockerfile(self) -> Path:
|
|
78
|
+
return self.dockerfile_path or self.project_root / "Dockerfile"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def default_environment(self) -> enum.Enum:
|
|
82
|
+
"""Resolve ``default_environment_value`` against ``environments``."""
|
|
83
|
+
return self.environments(self.default_environment_value)
|
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
"""Run any Pysae CLI command in an ephemeral Kubernetes pod.
|
|
2
|
+
|
|
3
|
+
Two entry points:
|
|
4
|
+
|
|
5
|
+
- :func:`run_on_k8s` to dispatch programmatically.
|
|
6
|
+
- :func:`k8s_support` (or :func:`build_k8s_support` for a config-bound
|
|
7
|
+
variant) — a decorator on a Typer command that injects
|
|
8
|
+
``--k8s`` / ``--k8s-environment`` / ``--k8s-from-local-sources``
|
|
9
|
+
options into its signature so they show up in ``<cmd> --help``
|
|
10
|
+
exactly as if they had been declared by hand. When ``--k8s`` is
|
|
11
|
+
set, the decorator intercepts the run and dispatches to k8s
|
|
12
|
+
instead of executing the local body.
|
|
13
|
+
|
|
14
|
+
Every project-specific knob (image, source layout, forwarded envvars,
|
|
15
|
+
redacted options, …) lives in :class:`K8sConfig` — see its docstring
|
|
16
|
+
for the full contract.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import enum
|
|
21
|
+
import functools
|
|
22
|
+
import inspect
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import signal
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Annotated, Any, NoReturn, TypeVar
|
|
34
|
+
|
|
35
|
+
from typer import Option
|
|
36
|
+
|
|
37
|
+
from pysae_cli_tools.k8s.config import K8sConfig
|
|
38
|
+
|
|
39
|
+
_EXCLUDE_PATTERNS = [
|
|
40
|
+
"__pycache__",
|
|
41
|
+
"*.pyc",
|
|
42
|
+
".git",
|
|
43
|
+
"node_modules",
|
|
44
|
+
".mypy_cache",
|
|
45
|
+
".pytest_cache",
|
|
46
|
+
".ruff_cache",
|
|
47
|
+
"dist",
|
|
48
|
+
".venv",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _collect_forwarded_envvars(config: K8sConfig) -> dict[str, str]:
|
|
53
|
+
"""Return ``KEY -> VALUE`` for every forwarded envvar set locally."""
|
|
54
|
+
return {
|
|
55
|
+
key: os.environ[key] for key in config.forwarded_envvars if key in os.environ
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------- kubectl helpers --------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _kubectl(
|
|
63
|
+
*args: str,
|
|
64
|
+
context: str | None = None,
|
|
65
|
+
namespace: str | None = None,
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
"""Build a kubectl command, injecting ``--context``/``--namespace`` when set."""
|
|
68
|
+
cmd = ["kubectl"]
|
|
69
|
+
if context is not None:
|
|
70
|
+
cmd.append(f"--context={context}")
|
|
71
|
+
if namespace is not None:
|
|
72
|
+
cmd.append(f"--namespace={namespace}")
|
|
73
|
+
cmd.extend(args)
|
|
74
|
+
return cmd
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run(
|
|
78
|
+
cmd: list[str],
|
|
79
|
+
*,
|
|
80
|
+
check: bool = True,
|
|
81
|
+
capture: bool = False,
|
|
82
|
+
stream: bool = False,
|
|
83
|
+
inherit_stdin: bool = False,
|
|
84
|
+
) -> subprocess.CompletedProcess[str]:
|
|
85
|
+
"""Run a subprocess command with consistent defaults.
|
|
86
|
+
|
|
87
|
+
``inherit_stdin=True`` keeps the parent's stdin attached to the
|
|
88
|
+
child instead of redirecting to ``/dev/null``. Required when the
|
|
89
|
+
command is ``kubectl exec -i -t …`` — kubectl needs a real stdin
|
|
90
|
+
to negotiate the TTY allocation; ``DEVNULL`` would trip the
|
|
91
|
+
``Unable to use a TTY - input is not a terminal`` error.
|
|
92
|
+
"""
|
|
93
|
+
if capture:
|
|
94
|
+
return subprocess.run( # noqa: S603
|
|
95
|
+
cmd, check=check, text=True, capture_output=True
|
|
96
|
+
)
|
|
97
|
+
if stream:
|
|
98
|
+
return subprocess.run( # noqa: S603
|
|
99
|
+
cmd,
|
|
100
|
+
check=check,
|
|
101
|
+
text=True,
|
|
102
|
+
stdin=None if inherit_stdin else subprocess.DEVNULL,
|
|
103
|
+
stdout=sys.stdout,
|
|
104
|
+
stderr=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
return subprocess.run( # noqa: S603
|
|
107
|
+
cmd,
|
|
108
|
+
check=check,
|
|
109
|
+
text=True,
|
|
110
|
+
stdin=None if inherit_stdin else subprocess.DEVNULL,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------- image resolution ------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_base_image(dockerfile: Path) -> str:
|
|
118
|
+
"""Extract the production base image from the Dockerfile (``FROM <image> AS prod``).
|
|
119
|
+
|
|
120
|
+
Falls back to the first ``FROM`` directive when no ``AS prod`` is found —
|
|
121
|
+
matches the common single-stage layout.
|
|
122
|
+
"""
|
|
123
|
+
text = dockerfile.read_text()
|
|
124
|
+
prod_pattern = re.compile(r"^FROM\s+(\S+).*\bAS\s+prod", re.IGNORECASE)
|
|
125
|
+
for line in text.splitlines():
|
|
126
|
+
m = prod_pattern.match(line.strip())
|
|
127
|
+
if m:
|
|
128
|
+
return m.group(1)
|
|
129
|
+
first_pattern = re.compile(r"^FROM\s+(\S+)", re.IGNORECASE)
|
|
130
|
+
for line in text.splitlines():
|
|
131
|
+
m = first_pattern.match(line.strip())
|
|
132
|
+
if m:
|
|
133
|
+
return m.group(1)
|
|
134
|
+
msg = f"Could not find any 'FROM ...' directive in {dockerfile}"
|
|
135
|
+
raise RuntimeError(msg)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------- pod lifecycle ---------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _generate_pod_name(prefix: str) -> str:
|
|
142
|
+
short_id = uuid.uuid4().hex[:8]
|
|
143
|
+
return f"{prefix}-{short_id}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _create_pod(
|
|
147
|
+
pod_name: str,
|
|
148
|
+
image: str,
|
|
149
|
+
*,
|
|
150
|
+
workdir: str,
|
|
151
|
+
namespace: str | None = None,
|
|
152
|
+
context: str | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Create an ephemeral pod with ``sleep infinity`` and wait for Ready.
|
|
155
|
+
|
|
156
|
+
The ``cluster-autoscaler.kubernetes.io/safe-to-evict: "false"`` annotation
|
|
157
|
+
prevents the autoscaler from evicting the pod mid-run — restarting on a
|
|
158
|
+
different node would lose any in-flight state (source uploads, dump streams,
|
|
159
|
+
etc.) and force the operator to rerun from scratch.
|
|
160
|
+
"""
|
|
161
|
+
overrides: dict[str, object] = {
|
|
162
|
+
"apiVersion": "v1",
|
|
163
|
+
"metadata": {
|
|
164
|
+
"annotations": {
|
|
165
|
+
"cluster-autoscaler.kubernetes.io/safe-to-evict": "false",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
"spec": {
|
|
169
|
+
"containers": [
|
|
170
|
+
{
|
|
171
|
+
"name": "worker",
|
|
172
|
+
"image": image,
|
|
173
|
+
"command": ["sleep", "infinity"],
|
|
174
|
+
"workingDir": workdir,
|
|
175
|
+
}
|
|
176
|
+
],
|
|
177
|
+
"restartPolicy": "Never",
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_run(
|
|
182
|
+
_kubectl(
|
|
183
|
+
"run",
|
|
184
|
+
pod_name,
|
|
185
|
+
f"--image={image}",
|
|
186
|
+
"--restart=Never",
|
|
187
|
+
f"--overrides={json.dumps(overrides)}",
|
|
188
|
+
context=context,
|
|
189
|
+
namespace=namespace,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
print(f" Pod {pod_name} created, waiting for Ready...")
|
|
193
|
+
_run(
|
|
194
|
+
_kubectl(
|
|
195
|
+
"wait",
|
|
196
|
+
f"pod/{pod_name}",
|
|
197
|
+
"--for=condition=Ready",
|
|
198
|
+
"--timeout=120s",
|
|
199
|
+
context=context,
|
|
200
|
+
namespace=namespace,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
print(f" Pod {pod_name} is Ready.")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _copy_to_pod(
|
|
207
|
+
pod_name: str,
|
|
208
|
+
paths: list[str],
|
|
209
|
+
*,
|
|
210
|
+
project_root: Path,
|
|
211
|
+
workdir: str,
|
|
212
|
+
namespace: str | None = None,
|
|
213
|
+
context: str | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Copy local paths into the pod via tar + ``kubectl cp``."""
|
|
216
|
+
exclude_args: list[str] = []
|
|
217
|
+
for pat in _EXCLUDE_PATTERNS:
|
|
218
|
+
exclude_args.extend(["--exclude", pat])
|
|
219
|
+
|
|
220
|
+
paths_to_copy = [p for p in paths if (project_root / p).exists()]
|
|
221
|
+
if not paths_to_copy:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=True) as tmp:
|
|
225
|
+
_run(
|
|
226
|
+
[
|
|
227
|
+
"tar",
|
|
228
|
+
"czf",
|
|
229
|
+
tmp.name,
|
|
230
|
+
*exclude_args,
|
|
231
|
+
"-C",
|
|
232
|
+
str(project_root),
|
|
233
|
+
*paths_to_copy,
|
|
234
|
+
],
|
|
235
|
+
)
|
|
236
|
+
_run(
|
|
237
|
+
_kubectl(
|
|
238
|
+
"cp",
|
|
239
|
+
tmp.name,
|
|
240
|
+
f"{pod_name}:{workdir}/_sources.tar.gz",
|
|
241
|
+
"-c",
|
|
242
|
+
"worker",
|
|
243
|
+
context=context,
|
|
244
|
+
namespace=namespace,
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
_run(
|
|
249
|
+
_kubectl(
|
|
250
|
+
"exec",
|
|
251
|
+
pod_name,
|
|
252
|
+
"-c",
|
|
253
|
+
"worker",
|
|
254
|
+
"--",
|
|
255
|
+
"tar",
|
|
256
|
+
"xzf",
|
|
257
|
+
f"{workdir}/_sources.tar.gz",
|
|
258
|
+
"-C",
|
|
259
|
+
workdir,
|
|
260
|
+
context=context,
|
|
261
|
+
namespace=namespace,
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
_run(
|
|
265
|
+
_kubectl(
|
|
266
|
+
"exec",
|
|
267
|
+
pod_name,
|
|
268
|
+
"-c",
|
|
269
|
+
"worker",
|
|
270
|
+
"--",
|
|
271
|
+
"rm",
|
|
272
|
+
f"{workdir}/_sources.tar.gz",
|
|
273
|
+
context=context,
|
|
274
|
+
namespace=namespace,
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _install_dependencies(
|
|
280
|
+
pod_name: str,
|
|
281
|
+
install_script: str,
|
|
282
|
+
*,
|
|
283
|
+
namespace: str | None = None,
|
|
284
|
+
context: str | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Run the project's bootstrap script inside the pod."""
|
|
287
|
+
print(" Installing dependencies in pod...")
|
|
288
|
+
_run(
|
|
289
|
+
_kubectl(
|
|
290
|
+
"exec",
|
|
291
|
+
pod_name,
|
|
292
|
+
"-c",
|
|
293
|
+
"worker",
|
|
294
|
+
"--",
|
|
295
|
+
"bash",
|
|
296
|
+
"-c",
|
|
297
|
+
install_script,
|
|
298
|
+
context=context,
|
|
299
|
+
namespace=namespace,
|
|
300
|
+
),
|
|
301
|
+
stream=True,
|
|
302
|
+
)
|
|
303
|
+
print(" Dependencies installed.")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _exec_command(
|
|
307
|
+
pod_name: str,
|
|
308
|
+
command: list[str],
|
|
309
|
+
*,
|
|
310
|
+
stream: bool = True,
|
|
311
|
+
namespace: str | None = None,
|
|
312
|
+
context: str | None = None,
|
|
313
|
+
tty: bool = False,
|
|
314
|
+
) -> subprocess.CompletedProcess[str]:
|
|
315
|
+
"""Execute a command inside the pod (blocking).
|
|
316
|
+
|
|
317
|
+
``tty=True`` allocates a pseudo-TTY in the pod (``kubectl exec -i -t``)
|
|
318
|
+
so the command sees an interactive terminal — required for live progress
|
|
319
|
+
bars and color output. Callers must resolve auto-detection upstream; when
|
|
320
|
+
``tty=True`` is passed with a non-TTY local stdin, kubectl fails with
|
|
321
|
+
``Unable to use a TTY`` (that surface is intentional).
|
|
322
|
+
"""
|
|
323
|
+
tty_flags = ["-i", "-t"] if tty else []
|
|
324
|
+
return _run(
|
|
325
|
+
_kubectl(
|
|
326
|
+
"exec",
|
|
327
|
+
pod_name,
|
|
328
|
+
*tty_flags,
|
|
329
|
+
"-c",
|
|
330
|
+
"worker",
|
|
331
|
+
"--",
|
|
332
|
+
*command,
|
|
333
|
+
context=context,
|
|
334
|
+
namespace=namespace,
|
|
335
|
+
),
|
|
336
|
+
stream=stream,
|
|
337
|
+
check=False,
|
|
338
|
+
inherit_stdin=tty,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _delete_pod(
|
|
343
|
+
pod_name: str,
|
|
344
|
+
*,
|
|
345
|
+
namespace: str | None = None,
|
|
346
|
+
context: str | None = None,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Delete the ephemeral pod."""
|
|
349
|
+
print(f"\n Cleaning up pod {pod_name}...")
|
|
350
|
+
_run(
|
|
351
|
+
_kubectl(
|
|
352
|
+
"delete",
|
|
353
|
+
"pod",
|
|
354
|
+
pod_name,
|
|
355
|
+
"--grace-period=5",
|
|
356
|
+
"--wait=false",
|
|
357
|
+
context=context,
|
|
358
|
+
namespace=namespace,
|
|
359
|
+
),
|
|
360
|
+
check=False,
|
|
361
|
+
)
|
|
362
|
+
print(f" Pod {pod_name} deleted.")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ---------- remote command + redaction --------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _build_remote_command(
|
|
369
|
+
cli_module: str,
|
|
370
|
+
cli_subcommand: str | None,
|
|
371
|
+
args: list[str],
|
|
372
|
+
workdir: str,
|
|
373
|
+
env_overrides: dict[str, str] | None = None,
|
|
374
|
+
) -> list[str]:
|
|
375
|
+
"""Build the ``env … python -m <module> [subcommand] <args>`` command.
|
|
376
|
+
|
|
377
|
+
``PYTHONPATH=<workdir>`` makes the copied project package importable
|
|
378
|
+
in ``--k8s-from-local-sources`` mode (already on ``sys.path`` in the
|
|
379
|
+
deployed image where the wheel sits in site-packages, but the explicit
|
|
380
|
+
prefix is inoffensive there).
|
|
381
|
+
"""
|
|
382
|
+
cmd = [
|
|
383
|
+
"env",
|
|
384
|
+
f"PYTHONPATH={workdir}",
|
|
385
|
+
"PYTHONUNBUFFERED=1",
|
|
386
|
+
]
|
|
387
|
+
if env_overrides:
|
|
388
|
+
cmd.extend(f"{key}={value}" for key, value in env_overrides.items())
|
|
389
|
+
cmd.extend(["python", "-m", cli_module])
|
|
390
|
+
if cli_subcommand is not None:
|
|
391
|
+
cmd.append(cli_subcommand)
|
|
392
|
+
cmd.extend(args)
|
|
393
|
+
return cmd
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _redact_command(
|
|
397
|
+
cmd: list[str],
|
|
398
|
+
redacted_options: tuple[str, ...],
|
|
399
|
+
redacted_env_keys: frozenset[str] = frozenset(),
|
|
400
|
+
) -> list[str]:
|
|
401
|
+
"""Redact sensitive values in a printable form of the command.
|
|
402
|
+
|
|
403
|
+
Two sources of sensitivity are masked:
|
|
404
|
+
|
|
405
|
+
- Values right after a known sensitive option
|
|
406
|
+
(``--public-api-key X`` → ``--public-api-key ***``)
|
|
407
|
+
or as ``--public-api-key=X``.
|
|
408
|
+
- Env-var assignments whose key is in ``redacted_env_keys``
|
|
409
|
+
(``MONGO_URI=mongodb+srv://...`` → ``MONGO_URI=***``).
|
|
410
|
+
"""
|
|
411
|
+
redacted: list[str] = []
|
|
412
|
+
skip_next = False
|
|
413
|
+
for arg in cmd:
|
|
414
|
+
if skip_next:
|
|
415
|
+
redacted.append("***")
|
|
416
|
+
skip_next = False
|
|
417
|
+
continue
|
|
418
|
+
if arg in redacted_options:
|
|
419
|
+
redacted.append(arg)
|
|
420
|
+
skip_next = True
|
|
421
|
+
continue
|
|
422
|
+
if any(arg.startswith(f"{opt}=") for opt in redacted_options):
|
|
423
|
+
prefix = arg.split("=", 1)[0]
|
|
424
|
+
redacted.append(f"{prefix}=***")
|
|
425
|
+
continue
|
|
426
|
+
if "=" in arg and arg.split("=", 1)[0] in redacted_env_keys:
|
|
427
|
+
prefix = arg.split("=", 1)[0]
|
|
428
|
+
redacted.append(f"{prefix}=***")
|
|
429
|
+
continue
|
|
430
|
+
redacted.append(arg)
|
|
431
|
+
return redacted
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------- public dispatch -------------------------------------------------
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def run_on_k8s(
|
|
438
|
+
cli_args: list[str],
|
|
439
|
+
*,
|
|
440
|
+
config: K8sConfig,
|
|
441
|
+
cli_module: str,
|
|
442
|
+
cli_subcommand: str | None = None,
|
|
443
|
+
environment: str,
|
|
444
|
+
namespace: str | None = None,
|
|
445
|
+
context: str | None = None,
|
|
446
|
+
pod_name_prefix: str | None = None,
|
|
447
|
+
from_local_sources: bool = False,
|
|
448
|
+
env_overrides: dict[str, str] | None = None,
|
|
449
|
+
tty: bool | None = None,
|
|
450
|
+
) -> NoReturn:
|
|
451
|
+
"""Run ``python -m <cli_module> [<cli_subcommand>] <cli_args>`` in an ephemeral pod.
|
|
452
|
+
|
|
453
|
+
``cli_args`` are forwarded verbatim (the caller is responsible for
|
|
454
|
+
removing any k8s-only flags, see :func:`_filter_k8s_argv`).
|
|
455
|
+
|
|
456
|
+
``tty``: ``None`` (default) inherits from the local stdin —
|
|
457
|
+
interactive shell → TTY allocated in the pod, CI/pipe → not.
|
|
458
|
+
|
|
459
|
+
Exits the current process with the remote command's return code —
|
|
460
|
+
this function never returns.
|
|
461
|
+
"""
|
|
462
|
+
resolved_tty = tty if tty is not None else sys.stdin.isatty()
|
|
463
|
+
if from_local_sources:
|
|
464
|
+
image = _extract_base_image(config.resolved_dockerfile)
|
|
465
|
+
else:
|
|
466
|
+
image = config.default_image
|
|
467
|
+
resolved_pod_prefix = pod_name_prefix or config.default_pod_name_prefix
|
|
468
|
+
pod_name = _generate_pod_name(resolved_pod_prefix)
|
|
469
|
+
|
|
470
|
+
print(f"[K8S] Using image: {image}")
|
|
471
|
+
if context:
|
|
472
|
+
print(f"[K8S] Using context: {context}")
|
|
473
|
+
if namespace:
|
|
474
|
+
print(f"[K8S] Using namespace: {namespace}")
|
|
475
|
+
print(f"[K8S] Creating ephemeral pod: {pod_name}")
|
|
476
|
+
|
|
477
|
+
def _cleanup() -> None:
|
|
478
|
+
_delete_pod(pod_name, namespace=namespace, context=context)
|
|
479
|
+
|
|
480
|
+
atexit.register(_cleanup)
|
|
481
|
+
|
|
482
|
+
def _signal_handler(signum: int, frame: object) -> None:
|
|
483
|
+
_cleanup()
|
|
484
|
+
raise SystemExit(130)
|
|
485
|
+
|
|
486
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
_create_pod(
|
|
490
|
+
pod_name,
|
|
491
|
+
image,
|
|
492
|
+
workdir=config.workdir,
|
|
493
|
+
namespace=namespace,
|
|
494
|
+
context=context,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if from_local_sources:
|
|
498
|
+
if not config.copy_paths:
|
|
499
|
+
msg = (
|
|
500
|
+
"--k8s-from-local-sources requires K8sConfig.copy_paths to be set."
|
|
501
|
+
)
|
|
502
|
+
raise RuntimeError(msg)
|
|
503
|
+
print(" Copying sources into pod...")
|
|
504
|
+
_copy_to_pod(
|
|
505
|
+
pod_name,
|
|
506
|
+
list(config.copy_paths),
|
|
507
|
+
project_root=config.project_root,
|
|
508
|
+
workdir=config.workdir,
|
|
509
|
+
namespace=namespace,
|
|
510
|
+
context=context,
|
|
511
|
+
)
|
|
512
|
+
print(" Sources copied.")
|
|
513
|
+
if config.install_script:
|
|
514
|
+
_install_dependencies(
|
|
515
|
+
pod_name,
|
|
516
|
+
config.install_script,
|
|
517
|
+
namespace=namespace,
|
|
518
|
+
context=context,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
remote_cmd = _build_remote_command(
|
|
522
|
+
cli_module,
|
|
523
|
+
cli_subcommand,
|
|
524
|
+
cli_args,
|
|
525
|
+
workdir=config.workdir,
|
|
526
|
+
env_overrides=env_overrides,
|
|
527
|
+
)
|
|
528
|
+
redacted_env_keys = frozenset(env_overrides) if env_overrides else frozenset()
|
|
529
|
+
print(
|
|
530
|
+
f"[K8S] Running: "
|
|
531
|
+
f"{' '.join(_redact_command(remote_cmd, config.redacted_options, redacted_env_keys))}"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
result = _exec_command(
|
|
535
|
+
pod_name,
|
|
536
|
+
remote_cmd,
|
|
537
|
+
namespace=namespace,
|
|
538
|
+
context=context,
|
|
539
|
+
tty=resolved_tty,
|
|
540
|
+
)
|
|
541
|
+
result_rc = result.returncode
|
|
542
|
+
|
|
543
|
+
finally:
|
|
544
|
+
_delete_pod(pod_name, namespace=namespace, context=context)
|
|
545
|
+
atexit.unregister(_cleanup)
|
|
546
|
+
|
|
547
|
+
raise SystemExit(result_rc)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ---------- @k8s_support decorator ------------------------------------------
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
554
|
+
|
|
555
|
+
# Sentinel used to distinguish "auto-detect the subcommand from the
|
|
556
|
+
# enclosing Typer app at runtime" from the explicit ``None`` ("force
|
|
557
|
+
# no subcommand on the remote call") and from a string ("force this
|
|
558
|
+
# subcommand name").
|
|
559
|
+
_AUTO_SUBCOMMAND: Any = object()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _detect_subcommand_for(wrapper: Callable[..., Any], module_name: str) -> str | None:
|
|
563
|
+
"""Return the Typer subcommand name to forward to the pod.
|
|
564
|
+
|
|
565
|
+
Walks every ``typer.Typer`` instance defined in ``module_name`` and
|
|
566
|
+
locates the registered command whose underlying callback is the
|
|
567
|
+
``wrapper`` produced by :func:`k8s_support`. Returns ``None`` for
|
|
568
|
+
a single-command app (Typer auto-executes it remotely too), the
|
|
569
|
+
Typer command name otherwise, with a function-name fallback when
|
|
570
|
+
no enclosing app is found.
|
|
571
|
+
"""
|
|
572
|
+
import typer
|
|
573
|
+
|
|
574
|
+
fallback = wrapper.__name__.replace("_", "-")
|
|
575
|
+
module = sys.modules.get(module_name)
|
|
576
|
+
if module is None:
|
|
577
|
+
return fallback
|
|
578
|
+
typer_apps = [obj for obj in vars(module).values() if isinstance(obj, typer.Typer)]
|
|
579
|
+
for app in typer_apps:
|
|
580
|
+
for cmd in app.registered_commands:
|
|
581
|
+
if cmd.callback is wrapper:
|
|
582
|
+
if len(app.registered_commands) == 1:
|
|
583
|
+
return None
|
|
584
|
+
return cmd.name or fallback
|
|
585
|
+
return fallback
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _filter_k8s_argv(argv: list[str]) -> list[str]:
|
|
589
|
+
"""Strip ``--k8s``, ``--k8s-environment <v>``, ``--k8s-from-local-sources`` from ``argv``.
|
|
590
|
+
|
|
591
|
+
Also handles the ``--flag=value`` variants. Used to forward the
|
|
592
|
+
original CLI args to the pod without the k8s-specific options the
|
|
593
|
+
pod would not understand.
|
|
594
|
+
"""
|
|
595
|
+
out: list[str] = []
|
|
596
|
+
i = 0
|
|
597
|
+
while i < len(argv):
|
|
598
|
+
a = argv[i]
|
|
599
|
+
if a in ("--k8s", "--k8s-from-local-sources"):
|
|
600
|
+
i += 1
|
|
601
|
+
elif a == "--k8s-environment":
|
|
602
|
+
i += 2
|
|
603
|
+
elif (
|
|
604
|
+
a.startswith("--k8s=")
|
|
605
|
+
or a.startswith("--k8s-from-local-sources=")
|
|
606
|
+
or a.startswith("--k8s-environment=")
|
|
607
|
+
):
|
|
608
|
+
i += 1
|
|
609
|
+
else:
|
|
610
|
+
out.append(a)
|
|
611
|
+
i += 1
|
|
612
|
+
return out
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def k8s_support(
|
|
616
|
+
*,
|
|
617
|
+
config: K8sConfig,
|
|
618
|
+
cli_module: str | None = None,
|
|
619
|
+
cli_subcommand: Any = _AUTO_SUBCOMMAND,
|
|
620
|
+
pod_name_prefix: str | None = None,
|
|
621
|
+
tty: bool | None = None,
|
|
622
|
+
) -> Callable[[F], F]:
|
|
623
|
+
"""Inject k8s flags into a Typer command and dispatch when ``--k8s`` is set.
|
|
624
|
+
|
|
625
|
+
Applied to a function used as a Typer command body, this decorator
|
|
626
|
+
rewrites the function's signature to append three keyword-only
|
|
627
|
+
parameters:
|
|
628
|
+
|
|
629
|
+
- ``--k8s`` (bool, default False) — run on Kubernetes instead of locally.
|
|
630
|
+
- ``--k8s-environment`` (one of ``config.environments``) — target env.
|
|
631
|
+
- ``--k8s-from-local-sources`` (bool, default False) — copy local
|
|
632
|
+
sources and reinstall deps instead of using the deployed image
|
|
633
|
+
(slower, useful when developing).
|
|
634
|
+
|
|
635
|
+
Typer reads ``__signature__`` to generate ``--help``, so the three
|
|
636
|
+
flags appear under the command's own help just like manually
|
|
637
|
+
declared options. At runtime, if ``--k8s`` is set, the rest of
|
|
638
|
+
``sys.argv`` (with the k8s flags filtered out) is forwarded to
|
|
639
|
+
:func:`run_on_k8s` and the original function body is **not**
|
|
640
|
+
executed locally.
|
|
641
|
+
|
|
642
|
+
Pre-binding ``config`` once
|
|
643
|
+
--------------------------
|
|
644
|
+
When most commands in a project share the same config, use
|
|
645
|
+
:func:`build_k8s_support` to obtain a decorator with ``config``
|
|
646
|
+
already bound — the per-command application becomes
|
|
647
|
+
``@k8s_support()`` with no boilerplate.
|
|
648
|
+
|
|
649
|
+
Parameters
|
|
650
|
+
----------
|
|
651
|
+
config:
|
|
652
|
+
Project configuration. Drives image, source layout, secret
|
|
653
|
+
forwarding, redaction, and the set of supported environments.
|
|
654
|
+
cli_module:
|
|
655
|
+
Dotted path of the module to run inside the pod. Defaults to
|
|
656
|
+
``f.__module__`` (with the ``__main__`` fallback).
|
|
657
|
+
cli_subcommand:
|
|
658
|
+
Typer subcommand name. Auto-detected at runtime from the
|
|
659
|
+
enclosing Typer app by default.
|
|
660
|
+
pod_name_prefix:
|
|
661
|
+
Override ``config.default_pod_name_prefix`` for this command.
|
|
662
|
+
Helps disambiguate pods in ``kubectl get pods`` when several
|
|
663
|
+
commands run concurrently.
|
|
664
|
+
tty:
|
|
665
|
+
``None`` (default) inherits from the local stdin. Set
|
|
666
|
+
``True`` / ``False`` to force the behaviour.
|
|
667
|
+
"""
|
|
668
|
+
environments = config.environments
|
|
669
|
+
default_environment = config.default_environment
|
|
670
|
+
|
|
671
|
+
def decorator(f: F) -> F:
|
|
672
|
+
resolved_module = cli_module if cli_module is not None else f.__module__
|
|
673
|
+
# ``python -m foo.bar`` runs the module as ``__main__`` so
|
|
674
|
+
# ``f.__module__`` reads ``"__main__"`` — but the real dotted
|
|
675
|
+
# path lives in ``sys.modules["__main__"].__spec__.name``,
|
|
676
|
+
# populated by the runtime before the module body executes.
|
|
677
|
+
if resolved_module == "__main__":
|
|
678
|
+
main_mod = sys.modules.get("__main__")
|
|
679
|
+
spec = getattr(main_mod, "__spec__", None)
|
|
680
|
+
if spec is not None and spec.name:
|
|
681
|
+
resolved_module = spec.name
|
|
682
|
+
else:
|
|
683
|
+
msg = (
|
|
684
|
+
f"@k8s_support cannot auto-detect cli_module for {f.__qualname__} "
|
|
685
|
+
"(no __spec__.name on __main__ — script run directly via "
|
|
686
|
+
"``python script.py`` rather than ``python -m <module>``). "
|
|
687
|
+
"Pass cli_module=<dotted.path> explicitly."
|
|
688
|
+
)
|
|
689
|
+
raise RuntimeError(msg)
|
|
690
|
+
original_sig = inspect.signature(f)
|
|
691
|
+
|
|
692
|
+
k8s_param = inspect.Parameter(
|
|
693
|
+
"k8s",
|
|
694
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
695
|
+
default=False,
|
|
696
|
+
annotation=Annotated[
|
|
697
|
+
bool,
|
|
698
|
+
Option(
|
|
699
|
+
"--k8s",
|
|
700
|
+
help=(
|
|
701
|
+
"Run the command in an ephemeral pod on the Kubernetes cluster instead of locally."
|
|
702
|
+
),
|
|
703
|
+
),
|
|
704
|
+
],
|
|
705
|
+
)
|
|
706
|
+
k8s_env_param = inspect.Parameter(
|
|
707
|
+
"k8s_environment",
|
|
708
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
709
|
+
default=default_environment,
|
|
710
|
+
annotation=Annotated[
|
|
711
|
+
environments,
|
|
712
|
+
Option(
|
|
713
|
+
"--k8s-environment",
|
|
714
|
+
case_sensitive=False,
|
|
715
|
+
help="Target Kubernetes environment when --k8s is set.",
|
|
716
|
+
),
|
|
717
|
+
],
|
|
718
|
+
)
|
|
719
|
+
k8s_local_param = inspect.Parameter(
|
|
720
|
+
"k8s_from_local_sources",
|
|
721
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
722
|
+
default=False,
|
|
723
|
+
annotation=Annotated[
|
|
724
|
+
bool,
|
|
725
|
+
Option(
|
|
726
|
+
"--k8s-from-local-sources",
|
|
727
|
+
help=(
|
|
728
|
+
"When --k8s is set, copy local sources and "
|
|
729
|
+
"install deps in the pod instead of using the "
|
|
730
|
+
"deployed image (slower, useful when developing)."
|
|
731
|
+
),
|
|
732
|
+
),
|
|
733
|
+
],
|
|
734
|
+
)
|
|
735
|
+
new_params = list(original_sig.parameters.values()) + [
|
|
736
|
+
k8s_param,
|
|
737
|
+
k8s_env_param,
|
|
738
|
+
k8s_local_param,
|
|
739
|
+
]
|
|
740
|
+
new_sig = original_sig.replace(parameters=new_params)
|
|
741
|
+
|
|
742
|
+
@functools.wraps(f)
|
|
743
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
744
|
+
k8s = kwargs.pop("k8s", False)
|
|
745
|
+
k8s_environment: enum.Enum = kwargs.pop(
|
|
746
|
+
"k8s_environment", default_environment
|
|
747
|
+
)
|
|
748
|
+
k8s_from_local_sources = kwargs.pop("k8s_from_local_sources", False)
|
|
749
|
+
if not k8s:
|
|
750
|
+
return f(*args, **kwargs)
|
|
751
|
+
|
|
752
|
+
if cli_subcommand is _AUTO_SUBCOMMAND:
|
|
753
|
+
resolved_subcommand = _detect_subcommand_for(wrapper, f.__module__)
|
|
754
|
+
else:
|
|
755
|
+
resolved_subcommand = cli_subcommand
|
|
756
|
+
|
|
757
|
+
env_value: str = k8s_environment.value
|
|
758
|
+
env_overrides = _collect_forwarded_envvars(config)
|
|
759
|
+
run_on_k8s(
|
|
760
|
+
_filter_k8s_argv(sys.argv[1:]),
|
|
761
|
+
config=config,
|
|
762
|
+
cli_module=resolved_module,
|
|
763
|
+
cli_subcommand=resolved_subcommand,
|
|
764
|
+
environment=env_value,
|
|
765
|
+
namespace=env_value,
|
|
766
|
+
context=env_value,
|
|
767
|
+
pod_name_prefix=pod_name_prefix,
|
|
768
|
+
from_local_sources=k8s_from_local_sources,
|
|
769
|
+
env_overrides=env_overrides,
|
|
770
|
+
tty=tty,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
wrapper.__signature__ = new_sig # type: ignore[attr-defined]
|
|
774
|
+
return wrapper # type: ignore[return-value]
|
|
775
|
+
|
|
776
|
+
return decorator
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def build_k8s_support(config: K8sConfig) -> Callable[..., Callable[[F], F]]:
|
|
780
|
+
"""Return a :func:`k8s_support` decorator pre-bound to ``config``.
|
|
781
|
+
|
|
782
|
+
Typical setup at the top of a project's ``cli.py``::
|
|
783
|
+
|
|
784
|
+
from pysae_cli_tools.k8s import K8sConfig, build_k8s_support
|
|
785
|
+
|
|
786
|
+
K8S_CONFIG = K8sConfig(
|
|
787
|
+
default_image="...:latest",
|
|
788
|
+
project_root=Path(__file__).resolve().parents[1],
|
|
789
|
+
copy_paths=("src", "pyproject.toml", "poetry.lock"),
|
|
790
|
+
forwarded_envvars=("MY_API_KEY",),
|
|
791
|
+
redacted_options=("--api-key",),
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
k8s_support = build_k8s_support(K8S_CONFIG)
|
|
795
|
+
|
|
796
|
+
@app.command()
|
|
797
|
+
@k8s_support()
|
|
798
|
+
def my_command(...): ...
|
|
799
|
+
|
|
800
|
+
@app.command()
|
|
801
|
+
@k8s_support(pod_name_prefix="my-command-2") # override per-command
|
|
802
|
+
def my_other_command(...): ...
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
def k8s_support_bound(
|
|
806
|
+
*,
|
|
807
|
+
cli_module: str | None = None,
|
|
808
|
+
cli_subcommand: Any = _AUTO_SUBCOMMAND,
|
|
809
|
+
pod_name_prefix: str | None = None,
|
|
810
|
+
tty: bool | None = None,
|
|
811
|
+
) -> Callable[[F], F]:
|
|
812
|
+
return k8s_support(
|
|
813
|
+
config=config,
|
|
814
|
+
cli_module=cli_module,
|
|
815
|
+
cli_subcommand=cli_subcommand,
|
|
816
|
+
pod_name_prefix=pod_name_prefix,
|
|
817
|
+
tty=tty,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
return k8s_support_bound
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysae-cli-tools
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Reusable utilities for Pysae Python CLIs (k8s pod dispatch, …).
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Rémi Alvergnat
|
|
7
|
+
Author-email: remi.alvergnat@pysae.com
|
|
8
|
+
Requires-Python: >=3.11,<4
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: typer (>=0.25.1,<0.26.0)
|
|
16
|
+
Project-URL: Homepage, https://gitlab.com/pysae/tools/cli-tools
|
|
17
|
+
Project-URL: Repository, https://gitlab.com/pysae/tools/cli-tools
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pysae-cli-tools
|
|
21
|
+
|
|
22
|
+
Reusable utilities for Pysae Python CLIs.
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/pysae-cli-tools/)
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pysae-cli-tools
|
|
30
|
+
# or
|
|
31
|
+
poetry add pysae-cli-tools
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What's included
|
|
35
|
+
|
|
36
|
+
### `pysae_cli_tools.k8s` — run any Typer command in an ephemeral pod
|
|
37
|
+
|
|
38
|
+
The `@k8s_support` decorator injects three flags into a Typer command —
|
|
39
|
+
`--k8s`, `--k8s-environment {dev|prod}`, `--k8s-from-local-sources` — and
|
|
40
|
+
dispatches the call into a freshly-spawned Kubernetes pod when `--k8s` is set.
|
|
41
|
+
|
|
42
|
+
It is meant for CLIs that need to run inside the same network as their target
|
|
43
|
+
infrastructure (private-link databases, VPC-only APIs, …) without rewriting the
|
|
44
|
+
command for `kubectl run`.
|
|
45
|
+
|
|
46
|
+
#### Usage with `build_k8s_support` (recommended)
|
|
47
|
+
|
|
48
|
+
Most projects share the same `K8sConfig` across every decorated command —
|
|
49
|
+
declare it once and reuse the bound decorator everywhere:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
from typer import Typer
|
|
55
|
+
|
|
56
|
+
from pysae_cli_tools.k8s import K8sConfig, build_k8s_support
|
|
57
|
+
|
|
58
|
+
K8S_CONFIG = K8sConfig(
|
|
59
|
+
default_image="<registry>/<project>:latest",
|
|
60
|
+
project_root=Path(__file__).resolve().parents[1],
|
|
61
|
+
copy_paths=("my_pkg", "pyproject.toml", "poetry.lock"),
|
|
62
|
+
install_script=(
|
|
63
|
+
"apt-get update -qq && pip install poetry && "
|
|
64
|
+
"poetry config virtualenvs.create false && "
|
|
65
|
+
"poetry install --only main --no-interaction"
|
|
66
|
+
),
|
|
67
|
+
forwarded_envvars=("MY_API_KEY", "MY_DB_URI"),
|
|
68
|
+
redacted_options=("--api-key", "--password"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
k8s_support = build_k8s_support(K8S_CONFIG)
|
|
72
|
+
app = Typer()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
@k8s_support()
|
|
77
|
+
def my_command() -> None:
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command()
|
|
82
|
+
@k8s_support(pod_name_prefix="my-second-command") # override per-command
|
|
83
|
+
def my_second_command() -> None:
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
#### Usage with the explicit form
|
|
88
|
+
|
|
89
|
+
When you want to use a different config per command, pass it directly:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from pysae_cli_tools.k8s import K8sConfig, k8s_support
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
@k8s_support(config=K8S_CONFIG)
|
|
96
|
+
def my_command() -> None:
|
|
97
|
+
...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
#### What happens at runtime
|
|
101
|
+
|
|
102
|
+
When `--k8s` is set on the command line, the decorator:
|
|
103
|
+
|
|
104
|
+
1. Spawns an ephemeral pod using `K8sConfig.default_image` (or the Dockerfile
|
|
105
|
+
base image when `--k8s-from-local-sources` is also set).
|
|
106
|
+
2. Forwards every envvar listed in `K8sConfig.forwarded_envvars` from your
|
|
107
|
+
local shell into the pod's `env` block.
|
|
108
|
+
3. Runs `python -m <your.cli.module> <subcommand> <filtered argv>` inside the
|
|
109
|
+
pod, with values matching `K8sConfig.redacted_options` masked in the
|
|
110
|
+
`[K8S] Running:` log line.
|
|
111
|
+
4. Streams stdout/stderr back to your terminal and deletes the pod on exit.
|
|
112
|
+
|
|
113
|
+
See [`pysae_cli_tools/k8s/config.py`](pysae_cli_tools/k8s/config.py) for the
|
|
114
|
+
complete `K8sConfig` reference.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
poetry install
|
|
120
|
+
poetry run pre-commit install
|
|
121
|
+
poetry run pytest
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
CI publishes a new version to PyPI on every push to `main` — see
|
|
125
|
+
[`.gitlab-ci.yml`](.gitlab-ci.yml). The version is computed from
|
|
126
|
+
`git describe` via `pysae_cli_tools.compute_version`.
|
|
127
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pysae_cli_tools/__init__.py,sha256=amLEXwlkWCFpceSnwmsFgpSPy2ueVs53dAtE4PU2lvI,2499
|
|
2
|
+
pysae_cli_tools/k8s/__init__.py,sha256=jQGLAvgdVpNYV6-ZqLROo8lGYJybdnAa4qtzdISECKY,342
|
|
3
|
+
pysae_cli_tools/k8s/config.py,sha256=8BZsvR71plEaGupSN7eGadX1pdtVgfKpbttYbPjudAA,3189
|
|
4
|
+
pysae_cli_tools/k8s/runner.py,sha256=qI4BWU1_SCSk4dafdBcOEHQjt0j12ho3oEegv8biarw,25570
|
|
5
|
+
pysae_cli_tools-0.1.2.dist-info/METADATA,sha256=N2JmhQ5-wen7WDE5hsYnqHiPIrEY5Xz-ZXBgv-sRBqA,3818
|
|
6
|
+
pysae_cli_tools-0.1.2.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
|
|
7
|
+
pysae_cli_tools-0.1.2.dist-info/RECORD,,
|