squarepeg 0.3.0__tar.gz → 0.4.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.3.0 → squarepeg-0.4.1}/PKG-INFO +4 -1
- {squarepeg-0.3.0 → squarepeg-0.4.1}/README.md +3 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/pyproject.toml +1 -1
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/cli.py +117 -6
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/config.py +11 -1
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/dockerargs.py +0 -2
- squarepeg-0.4.1/squarepeg/k8s/events.py +90 -0
- squarepeg-0.4.1/squarepeg/k8s/orphans.py +324 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/runner.py +69 -25
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/session.py +23 -3
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/manifest.py +5 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/quantities.py +16 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/runspec.py +10 -1
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/ui.py +51 -14
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/PKG-INFO +4 -1
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_cli.py +288 -6
- squarepeg-0.4.1/tests/test_events.py +123 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_manifest.py +37 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_orphans.py +176 -15
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_quantities.py +12 -1
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_runner.py +123 -3
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_session.py +24 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_ui.py +22 -0
- squarepeg-0.3.0/squarepeg/k8s/events.py +0 -19
- squarepeg-0.3.0/squarepeg/k8s/orphans.py +0 -198
- squarepeg-0.3.0/tests/test_events.py +0 -49
- {squarepeg-0.3.0 → squarepeg-0.4.1}/setup.cfg +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/__init__.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/__main__.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/env_interpolation.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/errors.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/__init__.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/apierrors.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/dryrun.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/logs.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/podstate.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/labels.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/log.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/merge.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/naming.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/volumes.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/SOURCES.txt +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/dependency_links.txt +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/entry_points.txt +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/requires.txt +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/top_level.txt +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_apierrors.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_config.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_dockerargs.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_dryrun.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_env_interpolation.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_integration.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_logs.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_merge.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_naming.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_podstate.py +0 -0
- {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_volumes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: squarepeg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -49,6 +49,9 @@ Full documentation lives in the [wiki](https://github.com/BioWilko/squarepeg/wik
|
|
|
49
49
|
- [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting)
|
|
50
50
|
- [Development](https://github.com/BioWilko/squarepeg/wiki/Development)
|
|
51
51
|
|
|
52
|
+
> **MicroK8s on Python 3.13+:** if connecting fails with `CA cert does not include key usage extension`,
|
|
53
|
+
> set `lenient_tls: true` in your config — see [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting#runner-errors-exit-code-125).
|
|
54
|
+
|
|
52
55
|
See also [`examples/config.yaml`](examples/config.yaml) for a fully worked
|
|
53
56
|
config file.
|
|
54
57
|
|
|
@@ -35,6 +35,9 @@ Full documentation lives in the [wiki](https://github.com/BioWilko/squarepeg/wik
|
|
|
35
35
|
- [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting)
|
|
36
36
|
- [Development](https://github.com/BioWilko/squarepeg/wiki/Development)
|
|
37
37
|
|
|
38
|
+
> **MicroK8s on Python 3.13+:** if connecting fails with `CA cert does not include key usage extension`,
|
|
39
|
+
> set `lenient_tls: true` in your config — see [Troubleshooting](https://github.com/BioWilko/squarepeg/wiki/Troubleshooting#runner-errors-exit-code-125).
|
|
40
|
+
|
|
38
41
|
See also [`examples/config.yaml`](examples/config.yaml) for a fully worked
|
|
39
42
|
config file.
|
|
40
43
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "squarepeg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.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"
|
|
@@ -15,15 +15,16 @@ from squarepeg.dockerargs import (
|
|
|
15
15
|
parse_env_entries,
|
|
16
16
|
reject_unsupported,
|
|
17
17
|
)
|
|
18
|
-
from squarepeg.errors import SquarepegError, UsageError
|
|
18
|
+
from squarepeg.errors import RunnerError, SquarepegError, UsageError
|
|
19
19
|
from squarepeg.k8s.dryrun import server_dry_run
|
|
20
|
+
from squarepeg.k8s.orphans import DEFAULT_MIN_AGE_SECONDS, clean_orphans
|
|
20
21
|
from squarepeg.k8s.runner import run_manifest
|
|
21
22
|
from squarepeg.k8s.session import Session
|
|
22
23
|
from squarepeg.labels import RUN_ID_LABEL
|
|
23
24
|
from squarepeg.log import chatter
|
|
24
25
|
from squarepeg.manifest import build_job, build_pod
|
|
25
26
|
from squarepeg.naming import generate_name, validate_rfc1123
|
|
26
|
-
from squarepeg.quantities import docker_cpus_to_k8s, docker_memory_to_k8s
|
|
27
|
+
from squarepeg.quantities import docker_cpus_to_k8s, docker_gpus_to_k8s, docker_memory_to_k8s
|
|
27
28
|
from squarepeg.runspec import ResourceSpec, RunSpec
|
|
28
29
|
from squarepeg.volumes import auto_mounts_from_config, parse_volume_flag
|
|
29
30
|
|
|
@@ -64,7 +65,6 @@ def _unsupported_callback(ctx, param, value):
|
|
|
64
65
|
_UNSUPPORTED_GROUPS: list[tuple[list[str], bool]] = [
|
|
65
66
|
(["--network"], False),
|
|
66
67
|
(["--privileged"], True),
|
|
67
|
-
(["--gpus"], False),
|
|
68
68
|
(["-p", "--publish"], False),
|
|
69
69
|
(["-d", "--detach"], True),
|
|
70
70
|
(["--restart"], False),
|
|
@@ -154,6 +154,7 @@ def cli():
|
|
|
154
154
|
)
|
|
155
155
|
@click.option("--cpus", "cpus", default=None)
|
|
156
156
|
@click.option("-m", "--memory", "memory", default=None)
|
|
157
|
+
@click.option("--gpus", "gpus", default=None, help="GPU count (sets both request and limit)")
|
|
157
158
|
@click.option("--request-cpu", "request_cpu", default=None)
|
|
158
159
|
@click.option("--limit-cpu", "limit_cpu", default=None)
|
|
159
160
|
@click.option("--request-memory", "request_memory", default=None)
|
|
@@ -198,6 +199,7 @@ def run(
|
|
|
198
199
|
pull,
|
|
199
200
|
cpus,
|
|
200
201
|
memory,
|
|
202
|
+
gpus,
|
|
201
203
|
request_cpu,
|
|
202
204
|
limit_cpu,
|
|
203
205
|
request_memory,
|
|
@@ -300,6 +302,22 @@ def run(
|
|
|
300
302
|
if cli_set_memory and limit_memory is None:
|
|
301
303
|
claims.add(f"/spec/containers/[name={container_name}]/resources/limits/memory")
|
|
302
304
|
|
|
305
|
+
if "gpu_resource" in config_defaults:
|
|
306
|
+
resources.gpu_resource = str(config_defaults["gpu_resource"])
|
|
307
|
+
if "gpu_runtime_class" in config_defaults:
|
|
308
|
+
# null/"" disables it, for clusters where the NVIDIA runtime is the node default
|
|
309
|
+
resources.gpu_runtime_class = config_defaults["gpu_runtime_class"] or None
|
|
310
|
+
cli_set_gpus = gpus is not None
|
|
311
|
+
if gpus is None and "gpus" in config_defaults:
|
|
312
|
+
gpus = config_defaults["gpus"]
|
|
313
|
+
if gpus is not None:
|
|
314
|
+
resources.gpus = docker_gpus_to_k8s(gpus)
|
|
315
|
+
if cli_set_gpus:
|
|
316
|
+
for kind in ("requests", "limits"):
|
|
317
|
+
claims.add(
|
|
318
|
+
f"/spec/containers/[name={container_name}]/resources/{kind}/{resources.gpu_resource}"
|
|
319
|
+
)
|
|
320
|
+
|
|
303
321
|
if name is not None:
|
|
304
322
|
validate_rfc1123(name, what="--name")
|
|
305
323
|
claims.add("/metadata/name")
|
|
@@ -336,6 +354,7 @@ def run(
|
|
|
336
354
|
else coerce_int(resolved_config.get("timeout", 300), "timeout")
|
|
337
355
|
)
|
|
338
356
|
quiet = quiet or coerce_bool(resolved_config.get("quiet", False), "quiet")
|
|
357
|
+
lenient_tls = coerce_bool(resolved_config.get("lenient_tls", False), "lenient_tls")
|
|
339
358
|
cleanup = (
|
|
340
359
|
False if keep else coerce_bool(resolved_config.get("cleanup", True), "cleanup")
|
|
341
360
|
)
|
|
@@ -343,7 +362,7 @@ def run(
|
|
|
343
362
|
resolved_config.get("orphan_sweep", True), "orphan_sweep"
|
|
344
363
|
)
|
|
345
364
|
orphan_sweep_min_age = coerce_int(
|
|
346
|
-
resolved_config.get("orphan_sweep_min_age",
|
|
365
|
+
resolved_config.get("orphan_sweep_min_age", DEFAULT_MIN_AGE_SECONDS), "orphan_sweep_min_age"
|
|
347
366
|
)
|
|
348
367
|
if orphan_sweep_min_age < 0:
|
|
349
368
|
raise UsageError(
|
|
@@ -417,7 +436,7 @@ def run(
|
|
|
417
436
|
ui.set_run_tag(effective_run_id)
|
|
418
437
|
|
|
419
438
|
if dry_run_server:
|
|
420
|
-
session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
|
|
439
|
+
session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet, lenient_tls=lenient_tls)
|
|
421
440
|
spec.namespace = session.namespace
|
|
422
441
|
validated = server_dry_run(session, spec, manifest)
|
|
423
442
|
click.echo(yaml.safe_dump(validated, sort_keys=False), nl=False)
|
|
@@ -426,12 +445,104 @@ def run(
|
|
|
426
445
|
click.echo(yaml.safe_dump(manifest, sort_keys=False), nl=False)
|
|
427
446
|
return
|
|
428
447
|
|
|
429
|
-
session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet)
|
|
448
|
+
session = Session(namespace=spec.namespace, context=context, quiet=spec.quiet, lenient_tls=lenient_tls)
|
|
430
449
|
spec.namespace = session.namespace
|
|
431
450
|
exit_code = run_manifest(session, spec, manifest)
|
|
432
451
|
raise SystemExit(exit_code)
|
|
433
452
|
|
|
434
453
|
|
|
454
|
+
def _format_age(seconds: float) -> str:
|
|
455
|
+
"""kubectl-style compact duration: the two largest non-zero units, e.g. '10m12s',
|
|
456
|
+
'3h14m', '2d4h'. Used only for --dry-run's human-readable listing."""
|
|
457
|
+
seconds = int(seconds)
|
|
458
|
+
if seconds < 60:
|
|
459
|
+
return f"{seconds}s"
|
|
460
|
+
minutes, secs = divmod(seconds, 60)
|
|
461
|
+
if minutes < 60:
|
|
462
|
+
return f"{minutes}m{secs}s" if secs else f"{minutes}m"
|
|
463
|
+
hours, mins = divmod(minutes, 60)
|
|
464
|
+
if hours < 24:
|
|
465
|
+
return f"{hours}h{mins}m" if mins else f"{hours}h"
|
|
466
|
+
days, hrs = divmod(hours, 24)
|
|
467
|
+
return f"{days}d{hrs}h" if hrs else f"{days}d"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _candidate_detail(candidate) -> str:
|
|
471
|
+
age = _format_age(candidate.age_seconds)
|
|
472
|
+
if candidate.category == "terminal":
|
|
473
|
+
return f"finished {age} ago"
|
|
474
|
+
return f"{candidate.reason} for {age}"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@cli.command("clean")
|
|
478
|
+
@click.option("--dry-run", "dry_run", is_flag=True, help="list what would be deleted without deleting anything")
|
|
479
|
+
@click.option(
|
|
480
|
+
"--min-age",
|
|
481
|
+
"min_age",
|
|
482
|
+
type=int,
|
|
483
|
+
default=None,
|
|
484
|
+
help="seconds a resource must be eligible before it's cleaned (default: config orphan_sweep_min_age, else 300)",
|
|
485
|
+
)
|
|
486
|
+
@click.option("-n", "--namespace", "namespace", default=None)
|
|
487
|
+
@click.option("--context", "context", default=None, help="kubeconfig context to use")
|
|
488
|
+
@click.option("--quiet", "quiet", is_flag=True)
|
|
489
|
+
@click.option(
|
|
490
|
+
"--no-color",
|
|
491
|
+
"no_color",
|
|
492
|
+
is_flag=True,
|
|
493
|
+
help="disable colour/animation in squarepeg's own stderr output",
|
|
494
|
+
)
|
|
495
|
+
@_add_config_options
|
|
496
|
+
def clean(dry_run, min_age, namespace, context, quiet, no_color, config_paths, no_default_config, profile):
|
|
497
|
+
"""Delete this user's own orphaned Pods/Jobs left behind by crashed or killed runs.
|
|
498
|
+
|
|
499
|
+
Scoped to your own resources only (never a teammate's), and --keep'd resources are
|
|
500
|
+
always exempt -- there is no flag to override either. Runs regardless of the
|
|
501
|
+
'orphan_sweep' config key, which only affects the automatic per-run sweep. There is
|
|
502
|
+
no confirmation prompt: the candidates are, by construction, either already finished
|
|
503
|
+
or stuck and unable to start, so nothing running is ever at risk -- use --dry-run to
|
|
504
|
+
preview what would be deleted first if you want to check.
|
|
505
|
+
"""
|
|
506
|
+
ui.set_color_override(False if no_color else None)
|
|
507
|
+
ui.set_run_tag(secrets.token_hex(4))
|
|
508
|
+
|
|
509
|
+
resolved_config, _sources = _resolve_config(config_paths, no_default_config, profile)
|
|
510
|
+
|
|
511
|
+
if namespace is None:
|
|
512
|
+
namespace = resolved_config.get("namespace")
|
|
513
|
+
quiet = quiet or coerce_bool(resolved_config.get("quiet", False), "quiet")
|
|
514
|
+
lenient_tls = coerce_bool(resolved_config.get("lenient_tls", False), "lenient_tls")
|
|
515
|
+
|
|
516
|
+
if min_age is None:
|
|
517
|
+
min_age = coerce_int(
|
|
518
|
+
resolved_config.get("orphan_sweep_min_age", DEFAULT_MIN_AGE_SECONDS), "orphan_sweep_min_age"
|
|
519
|
+
)
|
|
520
|
+
if min_age < 0:
|
|
521
|
+
raise UsageError(f"'orphan_sweep_min_age' must not be negative, got {min_age}")
|
|
522
|
+
elif min_age < 0:
|
|
523
|
+
raise UsageError(f"--min-age must not be negative, got {min_age}")
|
|
524
|
+
|
|
525
|
+
session = Session(namespace=namespace, context=context, quiet=quiet, lenient_tls=lenient_tls)
|
|
526
|
+
|
|
527
|
+
with ui.Status("scanning for orphaned resources", quiet=quiet):
|
|
528
|
+
result = clean_orphans(session, min_age, quiet=quiet, dry_run=dry_run)
|
|
529
|
+
|
|
530
|
+
if dry_run:
|
|
531
|
+
if result.candidates:
|
|
532
|
+
kind_name_width = max(len(f"{c.kind}/{c.name}") for c in result.candidates)
|
|
533
|
+
category_width = max(len(c.category) for c in result.candidates)
|
|
534
|
+
for c in result.candidates:
|
|
535
|
+
kind_name = f"{c.kind}/{c.name}".ljust(kind_name_width)
|
|
536
|
+
category = c.category.ljust(category_width)
|
|
537
|
+
click.echo(f"{kind_name} {category} {_candidate_detail(c)}")
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
if result.failed:
|
|
541
|
+
raise RunnerError(
|
|
542
|
+
f"failed to delete {len(result.failed)} of {len(result.candidates)} orphaned resource(s)"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
435
546
|
def _print_config(config_paths, no_default_config, profile):
|
|
436
547
|
ui.set_run_tag(None) # config has no run context, even if invoked right after a 'run' in-process
|
|
437
548
|
resolved_config, sources = _resolve_config(config_paths, no_default_config, profile)
|
|
@@ -31,6 +31,7 @@ ALLOWED_TOP_KEYS = {
|
|
|
31
31
|
"timeout",
|
|
32
32
|
"quiet",
|
|
33
33
|
"allow_host_path_mounts",
|
|
34
|
+
"lenient_tls",
|
|
34
35
|
"split_streams",
|
|
35
36
|
"orphan_sweep",
|
|
36
37
|
"orphan_sweep_min_age",
|
|
@@ -40,7 +41,16 @@ ALLOWED_TOP_KEYS = {
|
|
|
40
41
|
"job",
|
|
41
42
|
"profiles",
|
|
42
43
|
}
|
|
43
|
-
ALLOWED_DEFAULTS_KEYS = {
|
|
44
|
+
ALLOWED_DEFAULTS_KEYS = {
|
|
45
|
+
"cpus",
|
|
46
|
+
"memory",
|
|
47
|
+
"gpus",
|
|
48
|
+
"gpu_resource",
|
|
49
|
+
"gpu_runtime_class",
|
|
50
|
+
"image_pull_policy",
|
|
51
|
+
"workdir",
|
|
52
|
+
"env",
|
|
53
|
+
}
|
|
44
54
|
|
|
45
55
|
|
|
46
56
|
def _resolve_env_paths() -> list[tuple[Path, str]]:
|
|
@@ -7,8 +7,6 @@ UNSUPPORTED: dict[str, str] = {
|
|
|
7
7
|
"--network": "container networking is a cluster-level concept; use the 'kubernetes' passthrough "
|
|
8
8
|
"section in your config (spec.dnsPolicy, spec.hostNetwork, etc.) if you need to control it",
|
|
9
9
|
"--privileged": "use 'kubernetes.spec.containers[].securityContext.privileged' in your config",
|
|
10
|
-
"--gpus": "request GPUs via 'kubernetes.spec.containers[].resources' (e.g. 'nvidia.com/gpu: 1') "
|
|
11
|
-
"in your config",
|
|
12
10
|
"-p": "there is no host to publish a port to on a remote cluster; use a Kubernetes Service instead",
|
|
13
11
|
"--publish": "there is no host to publish a port to on a remote cluster; use a Kubernetes Service instead",
|
|
14
12
|
"-d": "squarepeg always attaches to stream output; there is no detached mode",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Surface pod events as diagnostics: the difference between 'it hung' and
|
|
2
|
+
'0/12 nodes available: insufficient memory'.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from kubernetes.client.rest import ApiException
|
|
8
|
+
|
|
9
|
+
from squarepeg.log import chatter
|
|
10
|
+
|
|
11
|
+
# how often WarningEventWatcher re-lists events while waiting for a pod to start
|
|
12
|
+
EVENT_POLL_SECONDS = 3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _list_events(session, name: str) -> list:
|
|
16
|
+
try:
|
|
17
|
+
events = session.core.list_namespaced_event(
|
|
18
|
+
session.namespace, field_selector=f"involvedObject.name={name}"
|
|
19
|
+
)
|
|
20
|
+
except ApiException:
|
|
21
|
+
return [] # best-effort diagnostics; never fail the run because event listing failed
|
|
22
|
+
return list(events.items or [])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _event_key(event) -> tuple[str, str]:
|
|
26
|
+
return (event.reason, event.message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def print_pod_events(
|
|
30
|
+
session, pod_name: str, *, quiet: bool = False, skip: frozenset[tuple[str, str]] = frozenset()
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Print every event for `pod_name`, except those already shown (by (reason, message))."""
|
|
33
|
+
for event in _list_events(session, pod_name):
|
|
34
|
+
if _event_key(event) not in skip:
|
|
35
|
+
chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet, level="detail")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WarningEventWatcher:
|
|
39
|
+
"""Context manager that, while squarepeg waits on `name` (a pod, or a job waiting to
|
|
40
|
+
create one), polls its Warning events in the background and prints each new one as
|
|
41
|
+
soon as it appears.
|
|
42
|
+
|
|
43
|
+
These are usually the real answer to "why isn't it starting?" -- an unschedulable pod,
|
|
44
|
+
a missing RuntimeClass, an exceeded quota, a volume that won't mount -- and without
|
|
45
|
+
this they'd only be printed once --timeout had already expired. squarepeg still waits
|
|
46
|
+
out the timeout rather than failing on them, since some do resolve on their own (a
|
|
47
|
+
cluster autoscaler adding a node, a PVC binding), and says so once.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, session, name: str, *, timeout: int, quiet: bool = False):
|
|
51
|
+
self.session = session
|
|
52
|
+
self.name = name
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self.quiet = quiet
|
|
55
|
+
self.shown: set[tuple[str, str]] = set()
|
|
56
|
+
self._stop = threading.Event()
|
|
57
|
+
self._thread: threading.Thread | None = None
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> "WarningEventWatcher":
|
|
60
|
+
if not self.quiet:
|
|
61
|
+
self._thread = threading.Thread(target=self._poll, daemon=True)
|
|
62
|
+
self._thread.start()
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *_exc) -> bool:
|
|
66
|
+
self._stop.set()
|
|
67
|
+
if self._thread is not None:
|
|
68
|
+
self._thread.join(timeout=2)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def _poll(self) -> None:
|
|
72
|
+
while not self._stop.wait(EVENT_POLL_SECONDS):
|
|
73
|
+
self.report_new_warnings()
|
|
74
|
+
|
|
75
|
+
def report_new_warnings(self) -> None:
|
|
76
|
+
for event in _list_events(self.session, self.name):
|
|
77
|
+
key = _event_key(event)
|
|
78
|
+
if event.type != "Warning" or key in self.shown:
|
|
79
|
+
continue
|
|
80
|
+
first = not self.shown
|
|
81
|
+
self.shown.add(key)
|
|
82
|
+
chatter(f"{self.name}: {event.reason}: {event.message}", quiet=self.quiet, level="warn")
|
|
83
|
+
if first:
|
|
84
|
+
chatter(
|
|
85
|
+
f"this may not resolve on its own, but squarepeg will keep waiting until the "
|
|
86
|
+
f"{self.timeout}s --timeout in case it does (e.g. a node scaling up); "
|
|
87
|
+
"Ctrl+C to give up now",
|
|
88
|
+
quiet=self.quiet,
|
|
89
|
+
level="detail",
|
|
90
|
+
)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Opportunistic sweep for orphaned squarepeg-managed Pods/Jobs left behind by a
|
|
2
|
+
previous invocation that crashed or was killed before it could clean up after
|
|
3
|
+
itself (machine died, kill -9, network partition, ...).
|
|
4
|
+
|
|
5
|
+
Runs alongside -- never instead of -- the per-run cleanup in runner.py, and is
|
|
6
|
+
scoped strictly to the CURRENT user's own resources (never a teammate's, even
|
|
7
|
+
in a shared namespace) via the server-side label selector.
|
|
8
|
+
|
|
9
|
+
Two independent categories of orphan, because a resource that never managed to
|
|
10
|
+
start looks nothing like one that finished and wasn't deleted:
|
|
11
|
+
|
|
12
|
+
- terminal: Succeeded/Failed, older than the threshold since it finished.
|
|
13
|
+
- stuck: Pending with a container waiting on a reason squarepeg already
|
|
14
|
+
treats as fatal-on-sight (see podstate.FAIL_FAST_REASONS -- the
|
|
15
|
+
same set runner.py uses to fail fast on ITS OWN pod). A bare
|
|
16
|
+
Pod has no backoffLimit/TTL, so a pod stuck like this (e.g. a
|
|
17
|
+
typo'd image reference) never reaches Succeeded/Failed and
|
|
18
|
+
would otherwise sit in the namespace forever.
|
|
19
|
+
|
|
20
|
+
Never anything in Running/Pending-without-a-fail-fast-reason/Unknown, at any
|
|
21
|
+
age -- that's the load-bearing safety property of the whole feature.
|
|
22
|
+
|
|
23
|
+
`sweep_orphans` (used by runner.py's own per-run cleanup) and `clean_orphans`
|
|
24
|
+
(used by the standalone `squarepeg clean` command) share the same discovery
|
|
25
|
+
function but are deliberately separate orchestration loops: they differ in
|
|
26
|
+
output verbosity, error-hint text, and failure-reporting contract, and forcing
|
|
27
|
+
them into one function would need behaviour flags for what is otherwise a
|
|
28
|
+
ten-line loop.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
|
|
34
|
+
from kubernetes.client.rest import ApiException
|
|
35
|
+
|
|
36
|
+
from squarepeg.errors import ApiError
|
|
37
|
+
from squarepeg.k8s.apierrors import delete_resource, wrap_api_exception
|
|
38
|
+
from squarepeg.k8s.podstate import (
|
|
39
|
+
TERMINAL_PHASES,
|
|
40
|
+
job_terminal_age_seconds,
|
|
41
|
+
pod_age_seconds,
|
|
42
|
+
stuck_waiting_reason,
|
|
43
|
+
terminal_age_seconds,
|
|
44
|
+
)
|
|
45
|
+
from squarepeg.labels import (
|
|
46
|
+
CREATED_BY_LABEL,
|
|
47
|
+
KEEP_LABEL,
|
|
48
|
+
MANAGED_BY_LABEL,
|
|
49
|
+
MANAGED_BY_VALUE,
|
|
50
|
+
RUN_ID_LABEL,
|
|
51
|
+
current_created_by,
|
|
52
|
+
)
|
|
53
|
+
from squarepeg.log import chatter
|
|
54
|
+
|
|
55
|
+
DEFAULT_MIN_AGE_SECONDS = 300
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class OrphanCandidate:
|
|
60
|
+
kind: str # "pod" | "job"
|
|
61
|
+
name: str
|
|
62
|
+
category: str # "terminal" | "stuck"
|
|
63
|
+
age_seconds: float
|
|
64
|
+
reason: str | None = None # the fail-fast waiting reason, for category == "stuck"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class CleanResult:
|
|
69
|
+
candidates: list[OrphanCandidate] = field(default_factory=list)
|
|
70
|
+
deleted: list[OrphanCandidate] = field(default_factory=list)
|
|
71
|
+
already_gone: list[OrphanCandidate] = field(default_factory=list)
|
|
72
|
+
failed: list[tuple[OrphanCandidate, str]] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _label_selector(created_by: str) -> str:
|
|
76
|
+
return f"{MANAGED_BY_LABEL}={MANAGED_BY_VALUE},{CREATED_BY_LABEL}={created_by},!{KEEP_LABEL}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _job_name_for_pod(pod) -> str | None:
|
|
80
|
+
labels = pod.metadata.labels or {}
|
|
81
|
+
if "job-name" in labels:
|
|
82
|
+
return labels["job-name"]
|
|
83
|
+
for ref in pod.metadata.owner_references or []:
|
|
84
|
+
if ref.kind == "Job":
|
|
85
|
+
return ref.name
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_own_and_unexcluded(labels: dict, created_by: str, exclude_run_id: str | None) -> bool:
|
|
90
|
+
if labels.get(CREATED_BY_LABEL) != created_by:
|
|
91
|
+
return False
|
|
92
|
+
if exclude_run_id is not None and labels.get(RUN_ID_LABEL) == exclude_run_id:
|
|
93
|
+
return False
|
|
94
|
+
# belt-and-braces: the server-side selector already excludes anything carrying the keep
|
|
95
|
+
# label, but re-checking client-side means a selector typo can never turn into deleting
|
|
96
|
+
# a resource the user explicitly asked to keep.
|
|
97
|
+
if labels.get(KEEP_LABEL):
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pod_classification(pod, min_age_seconds: int, now: datetime) -> tuple[str, float, str | None] | None:
|
|
103
|
+
"""(category, age_seconds, reason) for a pod old enough to sweep under either category,
|
|
104
|
+
or None if it doesn't qualify at all."""
|
|
105
|
+
phase = pod.status.phase
|
|
106
|
+
if phase in TERMINAL_PHASES:
|
|
107
|
+
age = terminal_age_seconds(pod, now)
|
|
108
|
+
if age is not None and age >= min_age_seconds:
|
|
109
|
+
return "terminal", age, None
|
|
110
|
+
return None
|
|
111
|
+
reason = stuck_waiting_reason(pod) if phase == "Pending" else None
|
|
112
|
+
if reason is not None:
|
|
113
|
+
age = pod_age_seconds(pod, now)
|
|
114
|
+
if age is not None and age >= min_age_seconds:
|
|
115
|
+
return "stuck", age, reason
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _find_pods(pods, created_by: str, min_age_seconds: int, exclude_run_id: str | None, now: datetime):
|
|
120
|
+
"""Returns (bare_pod_candidates, stuck_child_job_info).
|
|
121
|
+
|
|
122
|
+
Job-owned pods are never deleted directly -- deleting a Job's child pod while the Job
|
|
123
|
+
still has a non-zero backoffLimit would make the controller spawn a replacement, i.e.
|
|
124
|
+
the sweep would start work rather than clean it up. Instead, a Job-owned pod that would
|
|
125
|
+
otherwise qualify is reported back so the caller can delete the parent Job instead,
|
|
126
|
+
which cascades to the pod via Background propagation.
|
|
127
|
+
"""
|
|
128
|
+
bare_candidates: list[OrphanCandidate] = []
|
|
129
|
+
stuck_child_job_info: dict[str, tuple[float, str | None]] = {}
|
|
130
|
+
|
|
131
|
+
for pod in pods:
|
|
132
|
+
labels = pod.metadata.labels or {}
|
|
133
|
+
if not _is_own_and_unexcluded(labels, created_by, exclude_run_id):
|
|
134
|
+
continue
|
|
135
|
+
classification = _pod_classification(pod, min_age_seconds, now)
|
|
136
|
+
if classification is None:
|
|
137
|
+
continue
|
|
138
|
+
category, age, reason = classification
|
|
139
|
+
|
|
140
|
+
job_name = _job_name_for_pod(pod)
|
|
141
|
+
if job_name is not None:
|
|
142
|
+
stuck_child_job_info[job_name] = (age, reason)
|
|
143
|
+
else:
|
|
144
|
+
bare_candidates.append(OrphanCandidate("pod", pod.metadata.name, category, age, reason))
|
|
145
|
+
|
|
146
|
+
return bare_candidates, stuck_child_job_info
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _find_jobs(
|
|
150
|
+
jobs,
|
|
151
|
+
created_by: str,
|
|
152
|
+
min_age_seconds: int,
|
|
153
|
+
exclude_run_id: str | None,
|
|
154
|
+
now: datetime,
|
|
155
|
+
stuck_child_job_info: dict,
|
|
156
|
+
):
|
|
157
|
+
candidates = []
|
|
158
|
+
for job in jobs:
|
|
159
|
+
labels = job.metadata.labels or {}
|
|
160
|
+
if not _is_own_and_unexcluded(labels, created_by, exclude_run_id):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
name = job.metadata.name
|
|
164
|
+
age = job_terminal_age_seconds(job, now)
|
|
165
|
+
if age is not None and age >= min_age_seconds:
|
|
166
|
+
candidates.append(OrphanCandidate("job", name, "terminal", age))
|
|
167
|
+
elif name in stuck_child_job_info:
|
|
168
|
+
# no Complete/Failed condition yet (e.g. backoffLimit: 0 means a stuck pull
|
|
169
|
+
# never produces a failure condition at all), but its child pod has been
|
|
170
|
+
# stuck on a fail-fast reason long enough -- same verdict, different signal.
|
|
171
|
+
child_age, reason = stuck_child_job_info[name]
|
|
172
|
+
candidates.append(OrphanCandidate("job", name, "stuck", child_age, reason))
|
|
173
|
+
return candidates
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def find_orphan_candidates(
|
|
177
|
+
session,
|
|
178
|
+
created_by: str,
|
|
179
|
+
min_age_seconds: int,
|
|
180
|
+
*,
|
|
181
|
+
exclude_run_id: str | None = None,
|
|
182
|
+
now: datetime | None = None,
|
|
183
|
+
) -> list[OrphanCandidate]:
|
|
184
|
+
"""Discover this user's own orphaned Pods/Jobs, with classification detail.
|
|
185
|
+
|
|
186
|
+
Server-side scoped to app.kubernetes.io/managed-by=squarepeg, squarepeg.io/created-by
|
|
187
|
+
matching `created_by`, and NOT carrying squarepeg.io/keep -- so a teammate's resource or
|
|
188
|
+
a deliberately --keep'd one is never even returned by the apiserver. Client-side filtered
|
|
189
|
+
by phase/waiting-reason and age.
|
|
190
|
+
|
|
191
|
+
`now` defaults to the real current time; tests pass a fixed value.
|
|
192
|
+
"""
|
|
193
|
+
now = now or datetime.now(timezone.utc)
|
|
194
|
+
selector = _label_selector(created_by)
|
|
195
|
+
|
|
196
|
+
pods = session.core.list_namespaced_pod(session.namespace, label_selector=selector).items
|
|
197
|
+
jobs = session.batch.list_namespaced_job(session.namespace, label_selector=selector).items
|
|
198
|
+
|
|
199
|
+
bare_pod_candidates, stuck_child_job_info = _find_pods(pods, created_by, min_age_seconds, exclude_run_id, now)
|
|
200
|
+
job_candidates = _find_jobs(jobs, created_by, min_age_seconds, exclude_run_id, now, stuck_child_job_info)
|
|
201
|
+
return bare_pod_candidates + job_candidates
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def find_orphaned_resources(
|
|
205
|
+
session,
|
|
206
|
+
created_by: str,
|
|
207
|
+
min_age_seconds: int,
|
|
208
|
+
*,
|
|
209
|
+
exclude_run_id: str | None = None,
|
|
210
|
+
now: datetime | None = None,
|
|
211
|
+
) -> list[tuple[str, str]]:
|
|
212
|
+
"""(kind, name) pairs only -- a thin wrapper over find_orphan_candidates for callers
|
|
213
|
+
that don't need the classification detail. Deletion is identical for every entry
|
|
214
|
+
regardless of which category matched."""
|
|
215
|
+
candidates = find_orphan_candidates(
|
|
216
|
+
session, created_by, min_age_seconds, exclude_run_id=exclude_run_id, now=now
|
|
217
|
+
)
|
|
218
|
+
return [(c.kind, c.name) for c in candidates]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def sweep_orphans(
|
|
222
|
+
session,
|
|
223
|
+
min_age_seconds: int,
|
|
224
|
+
*,
|
|
225
|
+
quiet: bool = False,
|
|
226
|
+
exclude_run_id: str | None = None,
|
|
227
|
+
now: datetime | None = None,
|
|
228
|
+
) -> int:
|
|
229
|
+
"""Discover and delete this user's own orphaned resources, as a side effect of a run's
|
|
230
|
+
own cleanup (see runner.py's finally block).
|
|
231
|
+
|
|
232
|
+
Best-effort throughout: a per-resource delete failure is logged and skipped rather than
|
|
233
|
+
raised, so one undeletable orphan never aborts the rest of the sweep. The caller
|
|
234
|
+
(run_manifest) is responsible for making sure a failure here can never change the
|
|
235
|
+
current invocation's own exit code -- see runner.py's finally block.
|
|
236
|
+
"""
|
|
237
|
+
created_by = current_created_by()
|
|
238
|
+
try:
|
|
239
|
+
candidates = find_orphaned_resources(
|
|
240
|
+
session, created_by, min_age_seconds, exclude_run_id=exclude_run_id, now=now
|
|
241
|
+
)
|
|
242
|
+
except ApiException as exc:
|
|
243
|
+
wrapped = wrap_api_exception(exc, "failed to list resources for orphan sweep")
|
|
244
|
+
raise ApiError(f"{wrapped} (set 'orphan_sweep: false' in config to disable)") from exc
|
|
245
|
+
|
|
246
|
+
deleted = 0
|
|
247
|
+
for kind, name in candidates:
|
|
248
|
+
try:
|
|
249
|
+
if delete_resource(session, kind, name):
|
|
250
|
+
deleted += 1
|
|
251
|
+
except Exception as exc: # one bad delete must not abort the rest of the sweep
|
|
252
|
+
chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=quiet, level="warn")
|
|
253
|
+
|
|
254
|
+
if deleted:
|
|
255
|
+
chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=quiet, level="success")
|
|
256
|
+
return deleted
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def clean_orphans(
|
|
260
|
+
session,
|
|
261
|
+
min_age_seconds: int,
|
|
262
|
+
*,
|
|
263
|
+
quiet: bool = False,
|
|
264
|
+
dry_run: bool = False,
|
|
265
|
+
now: datetime | None = None,
|
|
266
|
+
) -> CleanResult:
|
|
267
|
+
"""Discover and (unless dry_run) delete this user's own orphaned resources, for the
|
|
268
|
+
standalone `squarepeg clean` command.
|
|
269
|
+
|
|
270
|
+
Unlike sweep_orphans, per-resource failures are recorded in the returned CleanResult
|
|
271
|
+
rather than only logged -- clean has no run whose own exit code must stay independent
|
|
272
|
+
of sweep failures, so the caller (cli.py) uses the failure list to decide its own exit
|
|
273
|
+
code. Never excludes a "current run", since there is no current run to exclude.
|
|
274
|
+
"""
|
|
275
|
+
created_by = current_created_by()
|
|
276
|
+
try:
|
|
277
|
+
candidates = find_orphan_candidates(session, created_by, min_age_seconds, now=now)
|
|
278
|
+
except ApiException as exc:
|
|
279
|
+
wrapped = wrap_api_exception(exc, "failed to list resources for cleanup")
|
|
280
|
+
raise ApiError(f"{wrapped} (the clean command needs 'list' on pods and jobs in the namespace)") from exc
|
|
281
|
+
|
|
282
|
+
result = CleanResult(candidates=candidates)
|
|
283
|
+
if not candidates:
|
|
284
|
+
chatter("nothing to clean", quiet=quiet, level="info")
|
|
285
|
+
return result
|
|
286
|
+
if dry_run:
|
|
287
|
+
chatter(
|
|
288
|
+
f"would delete {len(candidates)} orphaned resource(s) (dry run; nothing was deleted)",
|
|
289
|
+
quiet=quiet,
|
|
290
|
+
level="info",
|
|
291
|
+
)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
chatter(f"found {len(candidates)} orphaned resource(s)", quiet=quiet, level="info")
|
|
295
|
+
for candidate in candidates:
|
|
296
|
+
try:
|
|
297
|
+
if delete_resource(session, candidate.kind, candidate.name):
|
|
298
|
+
result.deleted.append(candidate)
|
|
299
|
+
chatter(
|
|
300
|
+
f"deleted {candidate.kind} {candidate.name!r} "
|
|
301
|
+
f"({candidate.category}, {candidate.age_seconds:.0f}s)",
|
|
302
|
+
quiet=quiet,
|
|
303
|
+
level="success",
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
result.already_gone.append(candidate)
|
|
307
|
+
chatter(f"{candidate.kind} {candidate.name!r} was already gone", quiet=quiet, level="detail")
|
|
308
|
+
except Exception as exc: # one bad delete must not abort the rest of the cleanup
|
|
309
|
+
# delete_resource's own ApiError message already reads "failed to delete
|
|
310
|
+
# {kind} {name!r}: ..." -- don't wrap it a second time.
|
|
311
|
+
result.failed.append((candidate, str(exc)))
|
|
312
|
+
chatter(str(exc), quiet=quiet, level="warn")
|
|
313
|
+
|
|
314
|
+
summary = f"cleaned {len(result.deleted)} of {len(candidates)} orphaned resource(s)"
|
|
315
|
+
extras = []
|
|
316
|
+
if result.already_gone:
|
|
317
|
+
extras.append(f"{len(result.already_gone)} already gone")
|
|
318
|
+
if result.failed:
|
|
319
|
+
extras.append(f"{len(result.failed)} failed")
|
|
320
|
+
if extras:
|
|
321
|
+
summary += f" ({', '.join(extras)})"
|
|
322
|
+
chatter(summary, quiet=quiet, level="warn" if result.failed else "success")
|
|
323
|
+
|
|
324
|
+
return result
|