squarepeg 0.1.0__tar.gz → 0.2.1__tar.gz
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.
- {squarepeg-0.1.0 → squarepeg-0.2.1}/PKG-INFO +10 -1
- {squarepeg-0.1.0 → squarepeg-0.2.1}/README.md +9 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/pyproject.toml +1 -1
- squarepeg-0.2.1/squarepeg/__init__.py +6 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/cli.py +162 -39
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/events.py +1 -1
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/logs.py +2 -1
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/orphans.py +2 -2
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/runner.py +89 -21
- squarepeg-0.2.1/squarepeg/log.py +5 -0
- squarepeg-0.2.1/squarepeg/ui.py +190 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/PKG-INFO +10 -1
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/SOURCES.txt +2 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_cli.py +95 -9
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_runner.py +27 -0
- squarepeg-0.2.1/tests/test_ui.py +110 -0
- squarepeg-0.1.0/squarepeg/__init__.py +0 -1
- squarepeg-0.1.0/squarepeg/log.py +0 -6
- {squarepeg-0.1.0 → squarepeg-0.2.1}/setup.cfg +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/__main__.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/config.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/dockerargs.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/env_interpolation.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/errors.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/__init__.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/apierrors.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/dryrun.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/podstate.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/k8s/session.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/labels.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/manifest.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/merge.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/naming.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/quantities.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/runspec.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg/volumes.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/dependency_links.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/entry_points.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/requires.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/squarepeg.egg-info/top_level.txt +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_apierrors.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_config.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_dockerargs.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_dryrun.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_env_interpolation.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_events.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_integration.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_logs.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_manifest.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_merge.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_naming.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_orphans.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_podstate.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_quantities.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_session.py +0 -0
- {squarepeg-0.1.0 → squarepeg-0.2.1}/tests/test_volumes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: squarepeg
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -12,6 +12,15 @@ Requires-Dist: pytest>=8; extra == "dev"
|
|
|
12
12
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
13
13
|
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
14
14
|
|
|
15
|
+
```
|
|
16
|
+
_____
|
|
17
|
+
/ ___/____ ___ ______ _________ ____ ___ ____ _
|
|
18
|
+
\__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
|
|
19
|
+
___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
|
|
20
|
+
/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
|
|
21
|
+
/_/ /_/ /____/
|
|
22
|
+
```
|
|
23
|
+
|
|
15
24
|
# squarepeg
|
|
16
25
|
|
|
17
26
|
Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
```
|
|
2
|
+
_____
|
|
3
|
+
/ ___/____ ___ ______ _________ ____ ___ ____ _
|
|
4
|
+
\__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
|
|
5
|
+
___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
|
|
6
|
+
/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
|
|
7
|
+
/_/ /_/ /____/
|
|
8
|
+
```
|
|
9
|
+
|
|
1
10
|
# squarepeg
|
|
2
11
|
|
|
3
12
|
Run a `docker run`-style command as a Kubernetes Pod or Job, streaming its
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "squarepeg"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Run a docker-run-style command as a Kubernetes Pod or Job, streaming its output like a local process"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import click
|
|
2
2
|
import yaml
|
|
3
3
|
|
|
4
|
-
from squarepeg import __version__
|
|
5
|
-
from squarepeg.config import
|
|
6
|
-
|
|
4
|
+
from squarepeg import __version__, ui
|
|
5
|
+
from squarepeg.config import (
|
|
6
|
+
coerce_bool,
|
|
7
|
+
coerce_int,
|
|
8
|
+
load_effective_config,
|
|
9
|
+
select_profile,
|
|
10
|
+
)
|
|
11
|
+
from squarepeg.dockerargs import (
|
|
12
|
+
check_supported_image,
|
|
13
|
+
parse_env_entries,
|
|
14
|
+
reject_unsupported,
|
|
15
|
+
)
|
|
7
16
|
from squarepeg.errors import SquarepegError, UsageError
|
|
8
17
|
from squarepeg.k8s.dryrun import server_dry_run
|
|
9
18
|
from squarepeg.k8s.runner import run_manifest
|
|
10
19
|
from squarepeg.k8s.session import Session
|
|
20
|
+
from squarepeg.log import chatter
|
|
11
21
|
from squarepeg.manifest import build_job, build_pod
|
|
12
22
|
from squarepeg.naming import generate_name, validate_rfc1123
|
|
13
23
|
from squarepeg.quantities import docker_cpus_to_k8s, docker_memory_to_k8s
|
|
@@ -16,7 +26,11 @@ from squarepeg.volumes import auto_mounts_from_config, parse_volume_flag
|
|
|
16
26
|
|
|
17
27
|
_CONFIG_OPTIONS = [
|
|
18
28
|
click.option(
|
|
19
|
-
"--config",
|
|
29
|
+
"--config",
|
|
30
|
+
"config_paths",
|
|
31
|
+
multiple=True,
|
|
32
|
+
type=click.Path(),
|
|
33
|
+
help="repeatable, layered left-to-right",
|
|
20
34
|
),
|
|
21
35
|
click.option("--no-default-config", "no_default_config", is_flag=True),
|
|
22
36
|
click.option("--profile", "profile", default=None),
|
|
@@ -30,7 +44,9 @@ def _add_config_options(cmd):
|
|
|
30
44
|
|
|
31
45
|
|
|
32
46
|
def _resolve_config(config_paths, no_default_config, profile):
|
|
33
|
-
effective, sources = load_effective_config(
|
|
47
|
+
effective, sources = load_effective_config(
|
|
48
|
+
config_paths, no_default_config=no_default_config
|
|
49
|
+
)
|
|
34
50
|
resolved = select_profile(effective, profile)
|
|
35
51
|
return resolved, sources
|
|
36
52
|
|
|
@@ -74,7 +90,24 @@ def _add_unsupported_options(cmd):
|
|
|
74
90
|
return cmd
|
|
75
91
|
|
|
76
92
|
|
|
77
|
-
|
|
93
|
+
_LOGO = r"""
|
|
94
|
+
_____
|
|
95
|
+
/ ___/____ ___ ______ _________ ____ ___ ____ _
|
|
96
|
+
\__ \/ __ `/ / / / __ `/ ___/ _ \/ __ \/ _ \/ __ `/
|
|
97
|
+
___/ / /_/ / /_/ / /_/ / / / __/ /_/ / __/ /_/ /
|
|
98
|
+
/____/\__, /\__,_/\__,_/_/ \___/ .___/\___/\__, /
|
|
99
|
+
/_/ /_/ /____/
|
|
100
|
+
""".strip("\n")
|
|
101
|
+
|
|
102
|
+
class _LogoGroup(click.Group):
|
|
103
|
+
"""Prints the ASCII logo above the 'Usage:' line, rather than as part of the help body."""
|
|
104
|
+
|
|
105
|
+
def format_usage(self, ctx, formatter):
|
|
106
|
+
formatter.write(_LOGO + "\n\n")
|
|
107
|
+
super().format_usage(ctx, formatter)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@click.group(cls=_LogoGroup)
|
|
78
111
|
@click.version_option(__version__, prog_name="squarepeg")
|
|
79
112
|
def cli():
|
|
80
113
|
"""Run a docker-run-style command as a Kubernetes Pod or Job."""
|
|
@@ -84,14 +117,38 @@ def cli():
|
|
|
84
117
|
context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True},
|
|
85
118
|
)
|
|
86
119
|
@click.option("-e", "--env", "env_entries", multiple=True, help="KEY=VALUE, repeatable")
|
|
87
|
-
@click.option(
|
|
120
|
+
@click.option(
|
|
121
|
+
"-v",
|
|
122
|
+
"--volume",
|
|
123
|
+
"volume_entries",
|
|
124
|
+
multiple=True,
|
|
125
|
+
help="[NAME|/host]:/container[:ro|rw], repeatable",
|
|
126
|
+
)
|
|
88
127
|
@click.option("--name", "name", default=None, help="Pod/Job name")
|
|
89
128
|
@click.option("-w", "--workdir", "workdir", default=None)
|
|
90
|
-
@click.option(
|
|
91
|
-
|
|
129
|
+
@click.option(
|
|
130
|
+
"--entrypoint",
|
|
131
|
+
"entrypoint",
|
|
132
|
+
default=None,
|
|
133
|
+
help="Override the image ENTRYPOINT (-> k8s 'command')",
|
|
134
|
+
)
|
|
135
|
+
@click.option(
|
|
136
|
+
"-i",
|
|
137
|
+
"--interactive",
|
|
138
|
+
"interactive",
|
|
139
|
+
is_flag=True,
|
|
140
|
+
help="accepted; stdin is not forwarded",
|
|
141
|
+
)
|
|
92
142
|
@click.option("-t", "--tty", "tty", is_flag=True)
|
|
93
|
-
@click.option(
|
|
94
|
-
|
|
143
|
+
@click.option(
|
|
144
|
+
"--rm",
|
|
145
|
+
"rm_flag",
|
|
146
|
+
is_flag=True,
|
|
147
|
+
help="accepted no-op; cleanup is already the default",
|
|
148
|
+
)
|
|
149
|
+
@click.option(
|
|
150
|
+
"--pull", "pull", type=click.Choice(["always", "missing", "never"]), default=None
|
|
151
|
+
)
|
|
95
152
|
@click.option("--cpus", "cpus", default=None)
|
|
96
153
|
@click.option("-m", "--memory", "memory", default=None)
|
|
97
154
|
@click.option("--request-cpu", "request_cpu", default=None)
|
|
@@ -100,8 +157,12 @@ def cli():
|
|
|
100
157
|
@click.option("--limit-memory", "limit_memory", default=None)
|
|
101
158
|
@click.option("--mode", "mode", type=click.Choice(["pod", "job"]), default=None)
|
|
102
159
|
@click.option("-n", "--namespace", "namespace", default=None)
|
|
103
|
-
@click.option(
|
|
104
|
-
|
|
160
|
+
@click.option(
|
|
161
|
+
"--keep", "keep", is_flag=True, help="do not delete the pod/job after it finishes"
|
|
162
|
+
)
|
|
163
|
+
@click.option(
|
|
164
|
+
"--dry-run", "dry_run", is_flag=True, help="render the manifest without creating it"
|
|
165
|
+
)
|
|
105
166
|
@click.option(
|
|
106
167
|
"--dry-run-server",
|
|
107
168
|
"dry_run_server",
|
|
@@ -110,6 +171,12 @@ def cli():
|
|
|
110
171
|
)
|
|
111
172
|
@click.option("--timeout", "timeout", type=int, default=None)
|
|
112
173
|
@click.option("--quiet", "quiet", is_flag=True)
|
|
174
|
+
@click.option(
|
|
175
|
+
"--no-color",
|
|
176
|
+
"no_color",
|
|
177
|
+
is_flag=True,
|
|
178
|
+
help="disable colour/animation in squarepeg's own stderr output",
|
|
179
|
+
)
|
|
113
180
|
@click.option("--context", "context", default=None, help="kubeconfig context to use")
|
|
114
181
|
@click.option("--container-name", "container_name", default="main")
|
|
115
182
|
@click.argument("image")
|
|
@@ -142,22 +209,28 @@ def run(
|
|
|
142
209
|
profile,
|
|
143
210
|
timeout,
|
|
144
211
|
quiet,
|
|
212
|
+
no_color,
|
|
145
213
|
context,
|
|
146
214
|
container_name,
|
|
147
215
|
image,
|
|
148
216
|
command,
|
|
149
217
|
):
|
|
150
218
|
"""Run IMAGE [COMMAND...] as a Kubernetes Pod or Job."""
|
|
219
|
+
ui.set_color_override(False if no_color else None)
|
|
151
220
|
check_supported_image(image)
|
|
152
221
|
if rm_flag and keep:
|
|
153
222
|
raise UsageError("--rm and --keep are mutually exclusive")
|
|
154
223
|
if dry_run and dry_run_server:
|
|
155
224
|
raise UsageError("--dry-run and --dry-run-server are mutually exclusive")
|
|
156
225
|
|
|
157
|
-
resolved_config, _sources = _resolve_config(
|
|
226
|
+
resolved_config, _sources = _resolve_config(
|
|
227
|
+
config_paths, no_default_config, profile
|
|
228
|
+
)
|
|
158
229
|
config_defaults = resolved_config.get("defaults") or {}
|
|
159
230
|
config_volumes = resolved_config.get("volumes") or {}
|
|
160
|
-
allow_host_path_mounts = coerce_bool(
|
|
231
|
+
allow_host_path_mounts = coerce_bool(
|
|
232
|
+
resolved_config.get("allow_host_path_mounts", False), "allow_host_path_mounts"
|
|
233
|
+
)
|
|
161
234
|
|
|
162
235
|
claims: set[str] = set()
|
|
163
236
|
|
|
@@ -166,12 +239,18 @@ def run(
|
|
|
166
239
|
claims.add(f"/spec/containers/[name={container_name}]/env/[name={key}]")
|
|
167
240
|
|
|
168
241
|
cli_volumes = [
|
|
169
|
-
parse_volume_flag(
|
|
242
|
+
parse_volume_flag(
|
|
243
|
+
v,
|
|
244
|
+
config_volumes=config_volumes,
|
|
245
|
+
allow_host_path_mounts=allow_host_path_mounts,
|
|
246
|
+
)
|
|
170
247
|
for v in volume_entries
|
|
171
248
|
]
|
|
172
249
|
for vol in cli_volumes:
|
|
173
250
|
claims.add(f"/spec/volumes/[name={vol.volume_name}]")
|
|
174
|
-
claims.add(
|
|
251
|
+
claims.add(
|
|
252
|
+
f"/spec/containers/[name={container_name}]/volumeMounts/[name={vol.volume_name}]"
|
|
253
|
+
)
|
|
175
254
|
|
|
176
255
|
# config-declared auto-mounts (volumes.NAME with a mount_path) are not claimed -- they're
|
|
177
256
|
# config-driven, not CLI-driven, so kubernetes.spec passthrough in the same config can still
|
|
@@ -200,7 +279,9 @@ def run(
|
|
|
200
279
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
|
|
201
280
|
if request_memory is not None:
|
|
202
281
|
resources.request_memory = request_memory
|
|
203
|
-
claims.add(
|
|
282
|
+
claims.add(
|
|
283
|
+
f"/spec/containers/[name={container_name}]/resources/requests/memory"
|
|
284
|
+
)
|
|
204
285
|
if limit_memory is not None:
|
|
205
286
|
resources.limit_memory = limit_memory
|
|
206
287
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
|
|
@@ -209,7 +290,9 @@ def run(
|
|
|
209
290
|
if cli_set_cpus and limit_cpu is None:
|
|
210
291
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/cpu")
|
|
211
292
|
if cli_set_memory and request_memory is None:
|
|
212
|
-
claims.add(
|
|
293
|
+
claims.add(
|
|
294
|
+
f"/spec/containers/[name={container_name}]/resources/requests/memory"
|
|
295
|
+
)
|
|
213
296
|
if cli_set_memory and limit_memory is None:
|
|
214
297
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
|
|
215
298
|
|
|
@@ -224,7 +307,11 @@ def run(
|
|
|
224
307
|
else:
|
|
225
308
|
namespace = resolved_config.get("namespace")
|
|
226
309
|
|
|
227
|
-
pull_policy =
|
|
310
|
+
pull_policy = (
|
|
311
|
+
{"always": "Always", "missing": "IfNotPresent", "never": "Never"}.get(pull)
|
|
312
|
+
if pull
|
|
313
|
+
else None
|
|
314
|
+
)
|
|
228
315
|
if pull_policy is not None:
|
|
229
316
|
claims.add(f"/spec/containers/[name={container_name}]/imagePullPolicy")
|
|
230
317
|
elif "image_pull_policy" in config_defaults:
|
|
@@ -236,13 +323,25 @@ def run(
|
|
|
236
323
|
workdir = config_defaults["workdir"]
|
|
237
324
|
|
|
238
325
|
mode = mode or resolved_config.get("mode", "pod")
|
|
239
|
-
timeout =
|
|
326
|
+
timeout = (
|
|
327
|
+
timeout
|
|
328
|
+
if timeout is not None
|
|
329
|
+
else coerce_int(resolved_config.get("timeout", 300), "timeout")
|
|
330
|
+
)
|
|
240
331
|
quiet = quiet or coerce_bool(resolved_config.get("quiet", False), "quiet")
|
|
241
|
-
cleanup =
|
|
242
|
-
|
|
243
|
-
|
|
332
|
+
cleanup = (
|
|
333
|
+
False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup")
|
|
334
|
+
)
|
|
335
|
+
orphan_sweep = coerce_bool(
|
|
336
|
+
resolved_config.get("orphan_sweep", True), "orphan_sweep"
|
|
337
|
+
)
|
|
338
|
+
orphan_sweep_min_age = coerce_int(
|
|
339
|
+
resolved_config.get("orphan_sweep_min_age", 300), "orphan_sweep_min_age"
|
|
340
|
+
)
|
|
244
341
|
if orphan_sweep_min_age < 0:
|
|
245
|
-
raise UsageError(
|
|
342
|
+
raise UsageError(
|
|
343
|
+
f"'orphan_sweep_min_age' must not be negative, got {orphan_sweep_min_age}"
|
|
344
|
+
)
|
|
246
345
|
|
|
247
346
|
entrypoint_tuple = (entrypoint,) if entrypoint is not None else None
|
|
248
347
|
if entrypoint_tuple is not None:
|
|
@@ -255,11 +354,14 @@ def run(
|
|
|
255
354
|
if not interactive:
|
|
256
355
|
# some clusters reject tty=true with stdin=false; -i is accepted but never
|
|
257
356
|
# forwards real stdin, so this can hang a container that then blocks reading it.
|
|
258
|
-
|
|
259
|
-
|
|
357
|
+
# This is a real hazard warning, so it's deliberately never suppressed by
|
|
358
|
+
# --quiet (quiet=False below), unlike squarepeg's other informational chatter.
|
|
359
|
+
chatter(
|
|
360
|
+
"-t implies stdin is opened on the container (stdinOnce), but stdin is "
|
|
260
361
|
"never actually forwarded; a process that blocks reading stdin will hang. Pass -i "
|
|
261
362
|
"explicitly to acknowledge this.",
|
|
262
|
-
|
|
363
|
+
quiet=False,
|
|
364
|
+
level="warn",
|
|
263
365
|
)
|
|
264
366
|
interactive = True
|
|
265
367
|
if interactive:
|
|
@@ -311,21 +413,38 @@ def run(
|
|
|
311
413
|
raise SystemExit(exit_code)
|
|
312
414
|
|
|
313
415
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
416
|
+
def _print_config(config_paths, no_default_config, profile):
|
|
417
|
+
resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
|
|
418
|
+
for path, origin in sources:
|
|
419
|
+
chatter(f"{path} ({origin})", level="info")
|
|
420
|
+
if not sources:
|
|
421
|
+
chatter("no config files loaded", level="info")
|
|
422
|
+
click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@cli.group("config", invoke_without_command=True)
|
|
426
|
+
@_add_config_options
|
|
427
|
+
@click.pass_context
|
|
428
|
+
def config_group(ctx, config_paths, no_default_config, profile):
|
|
429
|
+
"""Inspect the resolved config (same as 'config show')."""
|
|
430
|
+
if ctx.invoked_subcommand is None:
|
|
431
|
+
_print_config(config_paths, no_default_config, profile)
|
|
432
|
+
return
|
|
433
|
+
default_map = {}
|
|
434
|
+
if config_paths:
|
|
435
|
+
default_map["config_paths"] = config_paths
|
|
436
|
+
if no_default_config:
|
|
437
|
+
default_map["no_default_config"] = no_default_config
|
|
438
|
+
if profile is not None:
|
|
439
|
+
default_map["profile"] = profile
|
|
440
|
+
ctx.default_map = {"show": default_map}
|
|
317
441
|
|
|
318
442
|
|
|
319
443
|
@config_group.command("show")
|
|
320
444
|
@_add_config_options
|
|
321
445
|
def config_show(config_paths, no_default_config, profile):
|
|
322
|
-
"""Print the merged effective config (stdout) and its sources (stderr)."""
|
|
323
|
-
|
|
324
|
-
for path, origin in sources:
|
|
325
|
-
click.echo(f"[squarepeg] {path} ({origin})", err=True)
|
|
326
|
-
if not sources:
|
|
327
|
-
click.echo("[squarepeg] no config files loaded", err=True)
|
|
328
|
-
click.echo(yaml.safe_dump(resolved_config, sort_keys=False), nl=False)
|
|
446
|
+
"""Print the merged effective config (stdout) and its sources (stderr). Same as bare 'config'."""
|
|
447
|
+
_print_config(config_paths, no_default_config, profile)
|
|
329
448
|
|
|
330
449
|
|
|
331
450
|
def main():
|
|
@@ -337,7 +456,11 @@ def main():
|
|
|
337
456
|
except click.exceptions.Exit as exc:
|
|
338
457
|
raise SystemExit(exc.exit_code) from exc
|
|
339
458
|
except SquarepegError as exc:
|
|
340
|
-
click.echo(
|
|
459
|
+
click.echo(
|
|
460
|
+
click.style(f"squarepeg: {exc}", fg="red", bold=True),
|
|
461
|
+
err=True,
|
|
462
|
+
color=ui.color_enabled(),
|
|
463
|
+
)
|
|
341
464
|
raise SystemExit(exc.exit_code) from exc
|
|
342
465
|
|
|
343
466
|
|
|
@@ -16,4 +16,4 @@ def print_pod_events(session, pod_name: str, *, quiet: bool = False) -> None:
|
|
|
16
16
|
return # best-effort diagnostics; never fail the run because event listing failed
|
|
17
17
|
|
|
18
18
|
for event in events.items or []:
|
|
19
|
-
chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet)
|
|
19
|
+
chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet, level="detail")
|
|
@@ -115,12 +115,13 @@ def stream_logs(session, pod_name: str, container_name: str, stop_event, *, quie
|
|
|
115
115
|
f"log stream for pod {pod_name!r} failed after {attempt - 1} reconnect attempts ({exc}); "
|
|
116
116
|
"still waiting for the pod to finish",
|
|
117
117
|
quiet=quiet,
|
|
118
|
+
level="warn",
|
|
118
119
|
)
|
|
119
120
|
return
|
|
120
121
|
if dedupe.last_ts is not None:
|
|
121
122
|
since_seconds = since_seconds_from(dedupe.last_ts)
|
|
122
123
|
backoff = min(2**attempt, MAX_BACKOFF_SECONDS)
|
|
123
|
-
chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet)
|
|
124
|
+
chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet, level="warn")
|
|
124
125
|
time.sleep(backoff)
|
|
125
126
|
finally:
|
|
126
127
|
response.close()
|
|
@@ -191,8 +191,8 @@ def sweep_orphans(
|
|
|
191
191
|
if delete_resource(session, kind, name):
|
|
192
192
|
deleted += 1
|
|
193
193
|
except Exception as exc: # one bad delete must not abort the rest of the sweep
|
|
194
|
-
chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet)
|
|
194
|
+
chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet, level="warn")
|
|
195
195
|
|
|
196
196
|
if deleted:
|
|
197
|
-
chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet)
|
|
197
|
+
chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet, level="success")
|
|
198
198
|
return deleted
|
|
@@ -8,6 +8,7 @@ import threading
|
|
|
8
8
|
from kubernetes import watch
|
|
9
9
|
from kubernetes.client.rest import ApiException
|
|
10
10
|
|
|
11
|
+
from squarepeg import ui
|
|
11
12
|
from squarepeg.errors import InterruptError, RunnerError
|
|
12
13
|
from squarepeg.k8s.apierrors import delete_resource, wrap_api_exception
|
|
13
14
|
from squarepeg.k8s.events import print_pod_events
|
|
@@ -21,9 +22,9 @@ from squarepeg.runspec import RunSpec
|
|
|
21
22
|
# kept as module-level aliases: existing callers/tests referencing these names still work
|
|
22
23
|
_wrap_api_exception = wrap_api_exception
|
|
23
24
|
|
|
24
|
-
# how long a single watch call may block waiting for the pod to become terminal,
|
|
25
|
-
#
|
|
26
|
-
TERMINAL_WATCH_POLL_SECONDS =
|
|
25
|
+
# how long a single watch call may block waiting for the pod to become terminal, once it
|
|
26
|
+
# has already started; the loop simply re-watches if this elapses.
|
|
27
|
+
TERMINAL_WATCH_POLL_SECONDS = 5
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def create_resource(session, spec: RunSpec, manifest: dict) -> None:
|
|
@@ -48,13 +49,17 @@ def discover_job_pod(session, job_name: str, timeout: int) -> str:
|
|
|
48
49
|
):
|
|
49
50
|
return event["object"].metadata.name
|
|
50
51
|
except ApiException as exc:
|
|
51
|
-
raise _wrap_api_exception(
|
|
52
|
+
raise _wrap_api_exception(
|
|
53
|
+
exc, f"failed to watch for the pod created by job {job_name!r}"
|
|
54
|
+
) from exc
|
|
52
55
|
finally:
|
|
53
56
|
watcher.stop()
|
|
54
57
|
raise RunnerError(f"timed out waiting for job {job_name!r} to create a pod")
|
|
55
58
|
|
|
56
59
|
|
|
57
|
-
def wait_until_running_or_terminal(
|
|
60
|
+
def wait_until_running_or_terminal(
|
|
61
|
+
session, pod_name: str, timeout: int, *, quiet: bool = False
|
|
62
|
+
) -> str:
|
|
58
63
|
"""Wait until the pod is Running/Succeeded/Failed, or fail fast on a known-bad waiting reason.
|
|
59
64
|
|
|
60
65
|
This bounds only the *startup* window (image pull, scheduling, container create); once the
|
|
@@ -81,17 +86,23 @@ def _watch_for_start(session, pod_name: str, timeout: int) -> str:
|
|
|
81
86
|
for cs in pod.status.container_statuses or []:
|
|
82
87
|
waiting = cs.state.waiting
|
|
83
88
|
if waiting is not None and waiting.reason in FAIL_FAST_REASONS:
|
|
84
|
-
raise RunnerError(
|
|
89
|
+
raise RunnerError(
|
|
90
|
+
f"container failed to start ({waiting.reason}): {waiting.message}"
|
|
91
|
+
)
|
|
85
92
|
if phase in ("Running", "Succeeded", "Failed"):
|
|
86
93
|
return phase
|
|
87
94
|
except ApiException as exc:
|
|
88
95
|
raise _wrap_api_exception(exc, f"failed to watch pod {pod_name!r}") from exc
|
|
89
96
|
finally:
|
|
90
97
|
watcher.stop()
|
|
91
|
-
raise RunnerError(
|
|
98
|
+
raise RunnerError(
|
|
99
|
+
f"timed out after {timeout}s waiting for pod {pod_name!r} to start"
|
|
100
|
+
)
|
|
92
101
|
|
|
93
102
|
|
|
94
|
-
def wait_for_terminal(
|
|
103
|
+
def wait_for_terminal(
|
|
104
|
+
session, pod_name: str, stop_event: threading.Event
|
|
105
|
+
) -> str | None:
|
|
95
106
|
"""Wait, with no overall timeout, until the pod is Succeeded/Failed, or stop_event is set."""
|
|
96
107
|
while not stop_event.is_set():
|
|
97
108
|
watcher = watch.Watch()
|
|
@@ -115,7 +126,9 @@ def wait_for_terminal(session, pod_name: str, stop_event: threading.Event) -> st
|
|
|
115
126
|
return None
|
|
116
127
|
|
|
117
128
|
|
|
118
|
-
def extract_exit_code(
|
|
129
|
+
def extract_exit_code(
|
|
130
|
+
session, pod_name: str, container_name: str
|
|
131
|
+
) -> tuple[int, str | None]:
|
|
119
132
|
try:
|
|
120
133
|
pod = session.core.read_namespaced_pod(pod_name, session.namespace)
|
|
121
134
|
except ApiException as exc:
|
|
@@ -164,9 +177,12 @@ class _InterruptHandler:
|
|
|
164
177
|
chatter(
|
|
165
178
|
f"interrupted; cleaning up {self.mode} {self.name!r} (press Ctrl+C again to leave it running)",
|
|
166
179
|
quiet=self.quiet,
|
|
180
|
+
level="warn",
|
|
167
181
|
)
|
|
168
182
|
else:
|
|
169
|
-
chatter(
|
|
183
|
+
chatter(
|
|
184
|
+
f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn"
|
|
185
|
+
)
|
|
170
186
|
|
|
171
187
|
@property
|
|
172
188
|
def interrupted(self) -> bool:
|
|
@@ -184,10 +200,39 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
184
200
|
with _InterruptHandler(name, spec.mode, spec.quiet) as handler:
|
|
185
201
|
log_thread = None
|
|
186
202
|
try:
|
|
187
|
-
|
|
188
|
-
|
|
203
|
+
with ui.Status(
|
|
204
|
+
f"creating {spec.mode} {name!r} in namespace {session.namespace!r}",
|
|
205
|
+
quiet=spec.quiet,
|
|
206
|
+
):
|
|
207
|
+
create_resource(session, spec, manifest)
|
|
208
|
+
|
|
209
|
+
if spec.mode == "job":
|
|
210
|
+
with ui.Status(
|
|
211
|
+
f"waiting for job {name!r} to create a pod",
|
|
212
|
+
quiet=spec.quiet,
|
|
213
|
+
slow_hint="the job controller has not produced a pod yet",
|
|
214
|
+
slow_after=15,
|
|
215
|
+
):
|
|
216
|
+
pod_name = discover_job_pod(session, name, spec.timeout)
|
|
217
|
+
else:
|
|
218
|
+
pod_name = name
|
|
219
|
+
|
|
220
|
+
with ui.Status(
|
|
221
|
+
f"waiting for pod {pod_name!r} to start",
|
|
222
|
+
quiet=spec.quiet,
|
|
223
|
+
slow_hint="still scheduling or pulling the image; a cold pull of a large image can take a while",
|
|
224
|
+
slow_after=20,
|
|
225
|
+
spinner_delay=0.0,
|
|
226
|
+
):
|
|
227
|
+
phase = wait_until_running_or_terminal(
|
|
228
|
+
session, pod_name, spec.timeout, quiet=spec.quiet
|
|
229
|
+
)
|
|
189
230
|
|
|
190
|
-
|
|
231
|
+
chatter(
|
|
232
|
+
f"streaming logs from pod {pod_name!r} (waiting for it to finish; Ctrl+C to stop)",
|
|
233
|
+
quiet=spec.quiet,
|
|
234
|
+
level="step",
|
|
235
|
+
)
|
|
191
236
|
|
|
192
237
|
log_thread = threading.Thread(
|
|
193
238
|
target=stream_logs,
|
|
@@ -206,28 +251,51 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
206
251
|
# truncate output for fast-exiting containers.
|
|
207
252
|
log_thread.join(timeout=30)
|
|
208
253
|
if log_thread.is_alive():
|
|
209
|
-
chatter(
|
|
254
|
+
chatter(
|
|
255
|
+
f"log stream for pod {pod_name!r} did not finish on its own; stopping it",
|
|
256
|
+
quiet=spec.quiet,
|
|
257
|
+
level="warn",
|
|
258
|
+
)
|
|
210
259
|
handler.stop_event.set()
|
|
211
260
|
log_thread.join(timeout=5)
|
|
212
261
|
|
|
213
262
|
if handler.interrupted:
|
|
214
263
|
raise InterruptError(f"interrupted while running {spec.mode} {name!r}")
|
|
215
264
|
|
|
216
|
-
exit_code, reason = extract_exit_code(
|
|
265
|
+
exit_code, reason = extract_exit_code(
|
|
266
|
+
session, pod_name, spec.container_name
|
|
267
|
+
)
|
|
217
268
|
if reason == "OOMKilled":
|
|
218
|
-
chatter(
|
|
269
|
+
chatter(
|
|
270
|
+
f"container was OOMKilled (exit code {exit_code})",
|
|
271
|
+
quiet=spec.quiet,
|
|
272
|
+
level="error",
|
|
273
|
+
)
|
|
219
274
|
return exit_code
|
|
220
275
|
finally:
|
|
221
276
|
handler.stop_event.set()
|
|
222
277
|
if log_thread is not None:
|
|
223
278
|
log_thread.join(timeout=2)
|
|
224
279
|
if spec.cleanup and not handler.abandoned:
|
|
225
|
-
|
|
280
|
+
with ui.Status(f"deleting {spec.mode} {name!r}", quiet=spec.quiet):
|
|
281
|
+
cleanup(session, spec, name)
|
|
226
282
|
elif not handler.abandoned and not spec.quiet:
|
|
227
|
-
chatter(
|
|
283
|
+
chatter(
|
|
284
|
+
f"kept {spec.mode} {name!r}; inspect with 'kubectl describe {spec.mode} {name}'",
|
|
285
|
+
quiet=False,
|
|
286
|
+
level="warn",
|
|
287
|
+
)
|
|
228
288
|
if spec.orphan_sweep and not handler.abandoned:
|
|
229
|
-
own_run_id =
|
|
289
|
+
own_run_id = (
|
|
290
|
+
manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
|
|
291
|
+
)
|
|
230
292
|
try:
|
|
231
|
-
|
|
293
|
+
with ui.Status(
|
|
294
|
+
"sweeping orphaned resources from previous runs",
|
|
295
|
+
quiet=spec.quiet,
|
|
296
|
+
):
|
|
297
|
+
sweep_orphans(session, spec, exclude_run_id=own_run_id)
|
|
232
298
|
except Exception as exc:
|
|
233
|
-
chatter(
|
|
299
|
+
chatter(
|
|
300
|
+
f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn"
|
|
301
|
+
)
|