squarepeg 0.2.0__tar.gz → 0.3.0__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.2.0 → squarepeg-0.3.0}/PKG-INFO +1 -1
- {squarepeg-0.2.0 → squarepeg-0.3.0}/pyproject.toml +1 -1
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/cli.py +22 -2
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/runner.py +49 -17
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/ui.py +29 -1
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/PKG-INFO +1 -1
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_cli.py +96 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_ui.py +54 -1
- {squarepeg-0.2.0 → squarepeg-0.3.0}/README.md +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/setup.cfg +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/__init__.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/__main__.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/config.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/dockerargs.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/env_interpolation.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/errors.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/__init__.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/apierrors.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/dryrun.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/events.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/logs.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/orphans.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/podstate.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/k8s/session.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/labels.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/log.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/manifest.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/merge.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/naming.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/quantities.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/runspec.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg/volumes.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/SOURCES.txt +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/dependency_links.txt +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/entry_points.txt +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/requires.txt +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/squarepeg.egg-info/top_level.txt +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_apierrors.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_config.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_dockerargs.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_dryrun.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_env_interpolation.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_events.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_integration.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_logs.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_manifest.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_merge.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_naming.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_orphans.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_podstate.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_quantities.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_runner.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_session.py +0 -0
- {squarepeg-0.2.0 → squarepeg-0.3.0}/tests/test_volumes.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "squarepeg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
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,3 +1,5 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
|
|
1
3
|
import click
|
|
2
4
|
import yaml
|
|
3
5
|
|
|
@@ -17,6 +19,7 @@ from squarepeg.errors import SquarepegError, UsageError
|
|
|
17
19
|
from squarepeg.k8s.dryrun import server_dry_run
|
|
18
20
|
from squarepeg.k8s.runner import run_manifest
|
|
19
21
|
from squarepeg.k8s.session import Session
|
|
22
|
+
from squarepeg.labels import RUN_ID_LABEL
|
|
20
23
|
from squarepeg.log import chatter
|
|
21
24
|
from squarepeg.manifest import build_job, build_pod
|
|
22
25
|
from squarepeg.naming import generate_name, validate_rfc1123
|
|
@@ -217,6 +220,7 @@ def run(
|
|
|
217
220
|
):
|
|
218
221
|
"""Run IMAGE [COMMAND...] as a Kubernetes Pod or Job."""
|
|
219
222
|
ui.set_color_override(False if no_color else None)
|
|
223
|
+
ui.set_run_tag(None)
|
|
220
224
|
check_supported_image(image)
|
|
221
225
|
if rm_flag and keep:
|
|
222
226
|
raise UsageError("--rm and --keep are mutually exclusive")
|
|
@@ -302,6 +306,9 @@ def run(
|
|
|
302
306
|
else:
|
|
303
307
|
name = generate_name(image)
|
|
304
308
|
|
|
309
|
+
run_id = secrets.token_hex(4)
|
|
310
|
+
ui.set_run_tag(run_id)
|
|
311
|
+
|
|
305
312
|
if namespace is not None:
|
|
306
313
|
claims.add("/metadata/namespace")
|
|
307
314
|
else:
|
|
@@ -393,9 +400,21 @@ def run(
|
|
|
393
400
|
kubernetes_passthrough = resolved_config.get("kubernetes")
|
|
394
401
|
job_passthrough = resolved_config.get("job")
|
|
395
402
|
if spec.mode == "job":
|
|
396
|
-
manifest = build_job(spec, kubernetes_passthrough, job_passthrough)
|
|
403
|
+
manifest = build_job(spec, kubernetes_passthrough, job_passthrough, run_id=run_id)
|
|
397
404
|
else:
|
|
398
|
-
manifest = build_pod(spec, kubernetes_passthrough)
|
|
405
|
+
manifest = build_pod(spec, kubernetes_passthrough, run_id=run_id)
|
|
406
|
+
|
|
407
|
+
# kubernetes.metadata passthrough can legally overwrite the run-id label (it's not a
|
|
408
|
+
# claimed field) -- if it did, the tag on screen must match what's actually on the
|
|
409
|
+
# cluster, not the value we generated, so the printed tag is always kubectl -l ready.
|
|
410
|
+
effective_run_id = manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
|
|
411
|
+
if effective_run_id and effective_run_id != run_id:
|
|
412
|
+
chatter(
|
|
413
|
+
f"config passthrough overrode the {RUN_ID_LABEL!r} label "
|
|
414
|
+
f"(was {run_id!r}, now {effective_run_id!r})",
|
|
415
|
+
level="warn",
|
|
416
|
+
)
|
|
417
|
+
ui.set_run_tag(effective_run_id)
|
|
399
418
|
|
|
400
419
|
if dry_run_server:
|
|
401
420
|
session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
|
|
@@ -414,6 +433,7 @@ def run(
|
|
|
414
433
|
|
|
415
434
|
|
|
416
435
|
def _print_config(config_paths, no_default_config, profile):
|
|
436
|
+
ui.set_run_tag(None) # config has no run context, even if invoked right after a 'run' in-process
|
|
417
437
|
resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
|
|
418
438
|
for path, origin in sources:
|
|
419
439
|
chatter(f"{path} ({origin})", level="info")
|
|
@@ -22,9 +22,9 @@ from squarepeg.runspec import RunSpec
|
|
|
22
22
|
# kept as module-level aliases: existing callers/tests referencing these names still work
|
|
23
23
|
_wrap_api_exception = wrap_api_exception
|
|
24
24
|
|
|
25
|
-
# how long a single watch call may block waiting for the pod to become terminal,
|
|
26
|
-
#
|
|
27
|
-
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
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def create_resource(session, spec: RunSpec, manifest: dict) -> None:
|
|
@@ -49,13 +49,17 @@ def discover_job_pod(session, job_name: str, timeout: int) -> str:
|
|
|
49
49
|
):
|
|
50
50
|
return event["object"].metadata.name
|
|
51
51
|
except ApiException as exc:
|
|
52
|
-
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
|
|
53
55
|
finally:
|
|
54
56
|
watcher.stop()
|
|
55
57
|
raise RunnerError(f"timed out waiting for job {job_name!r} to create a pod")
|
|
56
58
|
|
|
57
59
|
|
|
58
|
-
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:
|
|
59
63
|
"""Wait until the pod is Running/Succeeded/Failed, or fail fast on a known-bad waiting reason.
|
|
60
64
|
|
|
61
65
|
This bounds only the *startup* window (image pull, scheduling, container create); once the
|
|
@@ -82,17 +86,23 @@ def _watch_for_start(session, pod_name: str, timeout: int) -> str:
|
|
|
82
86
|
for cs in pod.status.container_statuses or []:
|
|
83
87
|
waiting = cs.state.waiting
|
|
84
88
|
if waiting is not None and waiting.reason in FAIL_FAST_REASONS:
|
|
85
|
-
raise RunnerError(
|
|
89
|
+
raise RunnerError(
|
|
90
|
+
f"container failed to start ({waiting.reason}): {waiting.message}"
|
|
91
|
+
)
|
|
86
92
|
if phase in ("Running", "Succeeded", "Failed"):
|
|
87
93
|
return phase
|
|
88
94
|
except ApiException as exc:
|
|
89
95
|
raise _wrap_api_exception(exc, f"failed to watch pod {pod_name!r}") from exc
|
|
90
96
|
finally:
|
|
91
97
|
watcher.stop()
|
|
92
|
-
raise RunnerError(
|
|
98
|
+
raise RunnerError(
|
|
99
|
+
f"timed out after {timeout}s waiting for pod {pod_name!r} to start"
|
|
100
|
+
)
|
|
93
101
|
|
|
94
102
|
|
|
95
|
-
def wait_for_terminal(
|
|
103
|
+
def wait_for_terminal(
|
|
104
|
+
session, pod_name: str, stop_event: threading.Event
|
|
105
|
+
) -> str | None:
|
|
96
106
|
"""Wait, with no overall timeout, until the pod is Succeeded/Failed, or stop_event is set."""
|
|
97
107
|
while not stop_event.is_set():
|
|
98
108
|
watcher = watch.Watch()
|
|
@@ -116,7 +126,9 @@ def wait_for_terminal(session, pod_name: str, stop_event: threading.Event) -> st
|
|
|
116
126
|
return None
|
|
117
127
|
|
|
118
128
|
|
|
119
|
-
def extract_exit_code(
|
|
129
|
+
def extract_exit_code(
|
|
130
|
+
session, pod_name: str, container_name: str
|
|
131
|
+
) -> tuple[int, str | None]:
|
|
120
132
|
try:
|
|
121
133
|
pod = session.core.read_namespaced_pod(pod_name, session.namespace)
|
|
122
134
|
except ApiException as exc:
|
|
@@ -168,7 +180,9 @@ class _InterruptHandler:
|
|
|
168
180
|
level="warn",
|
|
169
181
|
)
|
|
170
182
|
else:
|
|
171
|
-
chatter(
|
|
183
|
+
chatter(
|
|
184
|
+
f"leaving {self.mode} {self.name!r} running", quiet=False, level="warn"
|
|
185
|
+
)
|
|
172
186
|
|
|
173
187
|
@property
|
|
174
188
|
def interrupted(self) -> bool:
|
|
@@ -186,7 +200,10 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
186
200
|
with _InterruptHandler(name, spec.mode, spec.quiet) as handler:
|
|
187
201
|
log_thread = None
|
|
188
202
|
try:
|
|
189
|
-
with ui.Status(
|
|
203
|
+
with ui.Status(
|
|
204
|
+
f"creating {spec.mode} {name!r} in namespace {session.namespace!r}",
|
|
205
|
+
quiet=spec.quiet,
|
|
206
|
+
):
|
|
190
207
|
create_resource(session, spec, manifest)
|
|
191
208
|
|
|
192
209
|
if spec.mode == "job":
|
|
@@ -207,7 +224,9 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
207
224
|
slow_after=20,
|
|
208
225
|
spinner_delay=0.0,
|
|
209
226
|
):
|
|
210
|
-
phase = wait_until_running_or_terminal(
|
|
227
|
+
phase = wait_until_running_or_terminal(
|
|
228
|
+
session, pod_name, spec.timeout, quiet=spec.quiet
|
|
229
|
+
)
|
|
211
230
|
|
|
212
231
|
chatter(
|
|
213
232
|
f"streaming logs from pod {pod_name!r} (waiting for it to finish; Ctrl+C to stop)",
|
|
@@ -243,9 +262,15 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
243
262
|
if handler.interrupted:
|
|
244
263
|
raise InterruptError(f"interrupted while running {spec.mode} {name!r}")
|
|
245
264
|
|
|
246
|
-
exit_code, reason = extract_exit_code(
|
|
265
|
+
exit_code, reason = extract_exit_code(
|
|
266
|
+
session, pod_name, spec.container_name
|
|
267
|
+
)
|
|
247
268
|
if reason == "OOMKilled":
|
|
248
|
-
chatter(
|
|
269
|
+
chatter(
|
|
270
|
+
f"container was OOMKilled (exit code {exit_code})",
|
|
271
|
+
quiet=spec.quiet,
|
|
272
|
+
level="error",
|
|
273
|
+
)
|
|
249
274
|
return exit_code
|
|
250
275
|
finally:
|
|
251
276
|
handler.stop_event.set()
|
|
@@ -261,9 +286,16 @@ def run_manifest(session, spec: RunSpec, manifest: dict) -> int:
|
|
|
261
286
|
level="warn",
|
|
262
287
|
)
|
|
263
288
|
if spec.orphan_sweep and not handler.abandoned:
|
|
264
|
-
own_run_id =
|
|
289
|
+
own_run_id = (
|
|
290
|
+
manifest.get("metadata", {}).get("labels", {}).get(RUN_ID_LABEL)
|
|
291
|
+
)
|
|
265
292
|
try:
|
|
266
|
-
with ui.Status(
|
|
293
|
+
with ui.Status(
|
|
294
|
+
"sweeping orphaned resources from previous runs",
|
|
295
|
+
quiet=spec.quiet,
|
|
296
|
+
):
|
|
267
297
|
sweep_orphans(session, spec, exclude_run_id=own_run_id)
|
|
268
298
|
except Exception as exc:
|
|
269
|
-
chatter(
|
|
299
|
+
chatter(
|
|
300
|
+
f"orphan sweep skipped: {exc}", quiet=spec.quiet, level="warn"
|
|
301
|
+
)
|
|
@@ -10,6 +10,12 @@ those call sites needed to change.
|
|
|
10
10
|
Colour/animation precedence (highest first): --quiet (nothing at all) > --no-color
|
|
11
11
|
(forces colour off) > $NO_COLOR (forces colour off) > $FORCE_COLOR (forces colour on) >
|
|
12
12
|
autodetect from sys.stderr.isatty() (click's own default behaviour).
|
|
13
|
+
|
|
14
|
+
When a run is in progress, the prefix also carries that run's id -- `[squarepeg:1a2b3c4]
|
|
15
|
+
...` -- so that several `squarepeg run` invocations whose stderr gets interleaved (parallel
|
|
16
|
+
shell jobs, a CI matrix, an aggregated log) stay attributable line by line. The tag lives
|
|
17
|
+
strictly in the prefix span, never inside the message body, so it can never break a test
|
|
18
|
+
asserting a message body is a contiguous substring of the output.
|
|
13
19
|
"""
|
|
14
20
|
|
|
15
21
|
import itertools
|
|
@@ -42,6 +48,8 @@ _ACTIVE: "Status | None" = None
|
|
|
42
48
|
|
|
43
49
|
_color_override: bool | None = None # None = no override, resolved from env/TTY instead
|
|
44
50
|
|
|
51
|
+
_run_tag: str | None = None # None = no run associated with current output (config, pre-run errors)
|
|
52
|
+
|
|
45
53
|
|
|
46
54
|
def set_color_override(value: bool | None) -> None:
|
|
47
55
|
"""Called once from cli.run() for --no-color. None restores auto-detection."""
|
|
@@ -49,6 +57,24 @@ def set_color_override(value: bool | None) -> None:
|
|
|
49
57
|
_color_override = value
|
|
50
58
|
|
|
51
59
|
|
|
60
|
+
def set_run_tag(tag: str | None) -> None:
|
|
61
|
+
"""Set the run id shown in every subsequent status line's prefix. None (the default,
|
|
62
|
+
and what cli.py's `config` command resets it to) means no run is associated with the
|
|
63
|
+
current output, producing the legacy plain `[squarepeg] ` prefix.
|
|
64
|
+
|
|
65
|
+
Written exactly once per real run, on the main thread, before any thread that reads it
|
|
66
|
+
(the log-streaming thread, a Status spinner thread) is created -- threading.Thread.start()
|
|
67
|
+
establishes a happens-before edge, so no lock is needed for readers. The signal handler in
|
|
68
|
+
_InterruptHandler also only reads this value, which is what makes it signal-handler-safe
|
|
69
|
+
to read (acquiring a lock there would not be)."""
|
|
70
|
+
global _run_tag
|
|
71
|
+
_run_tag = tag
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def current_run_tag() -> str | None:
|
|
75
|
+
return _run_tag
|
|
76
|
+
|
|
77
|
+
|
|
52
78
|
def color_enabled() -> bool | None:
|
|
53
79
|
"""Tri-state, passed straight through to click.echo(color=...): True/False force
|
|
54
80
|
colour on/off, None lets click autodetect from sys.stderr.isatty()."""
|
|
@@ -79,7 +105,9 @@ def _spinner_frames() -> list[str]:
|
|
|
79
105
|
|
|
80
106
|
|
|
81
107
|
def _styled_line(message: str, level: Level) -> str:
|
|
82
|
-
|
|
108
|
+
tag = current_run_tag()
|
|
109
|
+
label = f"[squarepeg:{tag}] " if tag else "[squarepeg] "
|
|
110
|
+
prefix = click.style(label, dim=True)
|
|
83
111
|
body = click.style(message, **_STYLES[level])
|
|
84
112
|
return prefix + body
|
|
85
113
|
|
|
@@ -7,6 +7,16 @@ from squarepeg.cli import cli
|
|
|
7
7
|
from squarepeg.errors import RunnerError
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
def _separated_runner():
|
|
11
|
+
"""A CliRunner with stdout/stderr captured separately, across the click 8.1/8.2 API
|
|
12
|
+
split: 8.1.x needs mix_stderr=False to get a usable .stderr; 8.2+ removed that kwarg
|
|
13
|
+
entirely and always separates the two streams by default."""
|
|
14
|
+
try:
|
|
15
|
+
return CliRunner(mix_stderr=False)
|
|
16
|
+
except TypeError:
|
|
17
|
+
return CliRunner()
|
|
18
|
+
|
|
19
|
+
|
|
10
20
|
class FakeSession:
|
|
11
21
|
"""Stands in for squarepeg.k8s.session.Session so CLI-level dry-run-server tests never
|
|
12
22
|
touch a real cluster; records the args it was constructed with for assertions."""
|
|
@@ -367,3 +377,89 @@ def test_config_unknown_subcommand_errors():
|
|
|
367
377
|
result = CliRunner().invoke(cli, ["config", "bogus"])
|
|
368
378
|
assert result.exit_code != 0
|
|
369
379
|
assert "No such command 'bogus'" in result.output
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# --- run-id tag ---
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _mock_run(monkeypatch):
|
|
386
|
+
"""Patches Session/run_manifest so 'run' never touches a cluster, and returns a dict
|
|
387
|
+
that gets filled in with the manifest run_manifest was actually handed."""
|
|
388
|
+
captured = {}
|
|
389
|
+
monkeypatch.setattr(cli_module, "Session", FakeSession)
|
|
390
|
+
|
|
391
|
+
def fake_run_manifest(session, spec, manifest):
|
|
392
|
+
captured["manifest"] = manifest
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
monkeypatch.setattr(cli_module, "run_manifest", fake_run_manifest)
|
|
396
|
+
return captured
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def test_run_stderr_tag_matches_manifest_run_id_label(monkeypatch):
|
|
400
|
+
"""-t (without -i) triggers a real chatter() warning inside cli.py itself, before Session
|
|
401
|
+
is even constructed -- a guaranteed, already-existing status line to check the tag on."""
|
|
402
|
+
import re
|
|
403
|
+
|
|
404
|
+
captured = _mock_run(monkeypatch)
|
|
405
|
+
result = _separated_runner().invoke(cli, ["run", "--no-default-config", "-t", "alpine"])
|
|
406
|
+
assert result.exit_code == 0, result.output
|
|
407
|
+
|
|
408
|
+
label = captured["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
|
|
409
|
+
assert re.fullmatch(r"[0-9a-f]{8}", label)
|
|
410
|
+
assert f"[squarepeg:{label}]" in result.stderr
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_run_job_mode_stderr_tag_matches_manifest_run_id_label(monkeypatch):
|
|
414
|
+
captured = _mock_run(monkeypatch)
|
|
415
|
+
result = _separated_runner().invoke(
|
|
416
|
+
cli, ["run", "--no-default-config", "--mode", "job", "-t", "alpine"]
|
|
417
|
+
)
|
|
418
|
+
assert result.exit_code == 0, result.output
|
|
419
|
+
|
|
420
|
+
label = captured["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
|
|
421
|
+
assert f"[squarepeg:{label}]" in result.stderr
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def test_config_output_has_no_run_tag():
|
|
425
|
+
result = _separated_runner().invoke(cli, ["config", "--no-default-config"])
|
|
426
|
+
assert result.exit_code == 0, result.stderr
|
|
427
|
+
assert "[squarepeg:" not in result.stderr
|
|
428
|
+
assert "[squarepeg] no config files loaded" in result.stderr
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_config_after_run_has_no_run_tag(monkeypatch):
|
|
432
|
+
"""A 'run' followed by 'config' in the same process (as CliRunner does) must not leak
|
|
433
|
+
the run's tag into config's output."""
|
|
434
|
+
_mock_run(monkeypatch)
|
|
435
|
+
_separated_runner().invoke(cli, ["run", "--no-default-config", "alpine"])
|
|
436
|
+
result = _separated_runner().invoke(cli, ["config", "--no-default-config"])
|
|
437
|
+
assert result.exit_code == 0, result.stderr
|
|
438
|
+
assert "[squarepeg:" not in result.stderr
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_dry_run_stdout_has_no_tag():
|
|
442
|
+
result = _separated_runner().invoke(cli, ["run", "--no-default-config", "--dry-run", "alpine"])
|
|
443
|
+
assert result.exit_code == 0, result.stderr
|
|
444
|
+
assert "[squarepeg" not in result.stdout
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def test_dry_run_server_stderr_is_tagged(monkeypatch):
|
|
448
|
+
monkeypatch.setattr(cli_module, "Session", FakeSession)
|
|
449
|
+
monkeypatch.setattr(cli_module, "server_dry_run", lambda session, spec, manifest: manifest)
|
|
450
|
+
result = _separated_runner().invoke(
|
|
451
|
+
cli, ["run", "--no-default-config", "-t", "--dry-run-server", "alpine"]
|
|
452
|
+
)
|
|
453
|
+
assert result.exit_code == 0, result.stderr
|
|
454
|
+
assert "[squarepeg:" in result.stderr
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_two_run_invocations_get_different_tags(monkeypatch):
|
|
458
|
+
captured1 = _mock_run(monkeypatch)
|
|
459
|
+
CliRunner().invoke(cli, ["run", "--no-default-config", "alpine"])
|
|
460
|
+
captured2 = _mock_run(monkeypatch)
|
|
461
|
+
CliRunner().invoke(cli, ["run", "--no-default-config", "alpine"])
|
|
462
|
+
|
|
463
|
+
label1 = captured1["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
|
|
464
|
+
label2 = captured2["manifest"]["metadata"]["labels"]["squarepeg.io/run-id"]
|
|
465
|
+
assert label1 != label2
|
|
@@ -8,10 +8,12 @@ LEVELS = ["info", "step", "wait", "success", "warn", "error", "detail"]
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@pytest.fixture(autouse=True)
|
|
11
|
-
def
|
|
11
|
+
def _reset_ui_state():
|
|
12
12
|
ui.set_color_override(None)
|
|
13
|
+
ui.set_run_tag(None)
|
|
13
14
|
yield
|
|
14
15
|
ui.set_color_override(None)
|
|
16
|
+
ui.set_run_tag(None)
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
# --- emit: colour / env var precedence ---
|
|
@@ -108,3 +110,54 @@ def test_status_propagates_exceptions_and_leaves_no_live_thread():
|
|
|
108
110
|
with ui.Status("doing a thing"):
|
|
109
111
|
raise RunnerError("boom")
|
|
110
112
|
assert threading.active_count() == before
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --- run tag ---
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_set_run_tag_appears_in_prefix(capsys):
|
|
119
|
+
ui.set_run_tag("abc12345")
|
|
120
|
+
ui.emit("hello")
|
|
121
|
+
assert "[squarepeg:abc12345] hello" in capsys.readouterr().err
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_no_run_tag_produces_legacy_prefix(capsys):
|
|
125
|
+
ui.emit("hello")
|
|
126
|
+
err = capsys.readouterr().err
|
|
127
|
+
assert err.strip() == "[squarepeg] hello"
|
|
128
|
+
assert "[squarepeg:" not in err
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.parametrize("level", LEVELS)
|
|
132
|
+
def test_run_tag_does_not_corrupt_body_substring(monkeypatch, capsys, level):
|
|
133
|
+
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
134
|
+
ui.set_run_tag("abc12345")
|
|
135
|
+
ui.emit("swept 3 orphaned resource(s) from previous runs", level=level)
|
|
136
|
+
assert "swept 3 orphaned resource(s) from previous runs" in capsys.readouterr().err
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_status_line_carries_run_tag(capsys):
|
|
140
|
+
ui.set_run_tag("abc12345")
|
|
141
|
+
with ui.Status("waiting for pod to start", slow_hint="still pulling", slow_after=999):
|
|
142
|
+
pass
|
|
143
|
+
err = capsys.readouterr().err
|
|
144
|
+
assert "[squarepeg:abc12345]" in err
|
|
145
|
+
assert "waiting for pod to start" in err
|
|
146
|
+
assert "still pulling" in err
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_status_success_line_carries_run_tag(capsys):
|
|
150
|
+
ui.set_run_tag("abc12345")
|
|
151
|
+
with ui.Status("doing a thing", success="all done"):
|
|
152
|
+
pass
|
|
153
|
+
err = capsys.readouterr().err
|
|
154
|
+
assert "[squarepeg:abc12345] all done" in err
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_set_run_tag_none_clears_a_previously_set_tag(capsys):
|
|
158
|
+
ui.set_run_tag("abc12345")
|
|
159
|
+
ui.set_run_tag(None)
|
|
160
|
+
ui.emit("hello")
|
|
161
|
+
err = capsys.readouterr().err
|
|
162
|
+
assert "[squarepeg:" not in err
|
|
163
|
+
assert "[squarepeg] hello" in err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|