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.
Files changed (57) hide show
  1. {squarepeg-0.3.0 → squarepeg-0.4.1}/PKG-INFO +4 -1
  2. {squarepeg-0.3.0 → squarepeg-0.4.1}/README.md +3 -0
  3. {squarepeg-0.3.0 → squarepeg-0.4.1}/pyproject.toml +1 -1
  4. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/cli.py +117 -6
  5. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/config.py +11 -1
  6. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/dockerargs.py +0 -2
  7. squarepeg-0.4.1/squarepeg/k8s/events.py +90 -0
  8. squarepeg-0.4.1/squarepeg/k8s/orphans.py +324 -0
  9. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/runner.py +69 -25
  10. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/session.py +23 -3
  11. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/manifest.py +5 -0
  12. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/quantities.py +16 -0
  13. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/runspec.py +10 -1
  14. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/ui.py +51 -14
  15. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/PKG-INFO +4 -1
  16. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_cli.py +288 -6
  17. squarepeg-0.4.1/tests/test_events.py +123 -0
  18. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_manifest.py +37 -0
  19. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_orphans.py +176 -15
  20. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_quantities.py +12 -1
  21. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_runner.py +123 -3
  22. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_session.py +24 -0
  23. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_ui.py +22 -0
  24. squarepeg-0.3.0/squarepeg/k8s/events.py +0 -19
  25. squarepeg-0.3.0/squarepeg/k8s/orphans.py +0 -198
  26. squarepeg-0.3.0/tests/test_events.py +0 -49
  27. {squarepeg-0.3.0 → squarepeg-0.4.1}/setup.cfg +0 -0
  28. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/__init__.py +0 -0
  29. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/__main__.py +0 -0
  30. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/env_interpolation.py +0 -0
  31. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/errors.py +0 -0
  32. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/__init__.py +0 -0
  33. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/apierrors.py +0 -0
  34. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/dryrun.py +0 -0
  35. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/logs.py +0 -0
  36. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/k8s/podstate.py +0 -0
  37. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/labels.py +0 -0
  38. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/log.py +0 -0
  39. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/merge.py +0 -0
  40. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/naming.py +0 -0
  41. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg/volumes.py +0 -0
  42. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/SOURCES.txt +0 -0
  43. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/dependency_links.txt +0 -0
  44. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/entry_points.txt +0 -0
  45. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/requires.txt +0 -0
  46. {squarepeg-0.3.0 → squarepeg-0.4.1}/squarepeg.egg-info/top_level.txt +0 -0
  47. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_apierrors.py +0 -0
  48. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_config.py +0 -0
  49. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_dockerargs.py +0 -0
  50. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_dryrun.py +0 -0
  51. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_env_interpolation.py +0 -0
  52. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_integration.py +0 -0
  53. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_logs.py +0 -0
  54. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_merge.py +0 -0
  55. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_naming.py +0 -0
  56. {squarepeg-0.3.0 → squarepeg-0.4.1}/tests/test_podstate.py +0 -0
  57. {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.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.3.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", 300), "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 = {"cpus", "memory", "image_pull_policy", "workdir", "env"}
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