squarepeg 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ """Substitute ${VAR} / ${VAR:-default} references in config YAML values.
2
+
3
+ Runs per-file, before validation and before merging -- so error messages can
4
+ name the exact source file, and everything downstream (validation, merging,
5
+ manifest building) only ever sees fully-resolved strings. Interpolation is
6
+ eager: every string leaf in the parsed document is walked, including inside
7
+ profile bodies that are not selected on this invocation (this matches
8
+ validate_document, which already validates every profile regardless of
9
+ selection) and inside the free-form 'kubernetes'/'job' passthrough sections
10
+ (interpolation is structural and doesn't care that those sections are
11
+ otherwise unvalidated).
12
+
13
+ Supported forms:
14
+ ${VAR} -- substitutes $VAR; a ConfigError if VAR is unset
15
+ ${VAR:-default} -- substitutes $VAR if set AND non-empty, else the
16
+ literal 'default' (bash ':-' semantics: an
17
+ empty-string value counts as unset)
18
+ $$ -- a literal '$'
19
+
20
+ Only ${...} references are recognised -- bare $VAR (no braces) is not a
21
+ reference and passes through untouched. A default is a literal: it is never
22
+ itself re-scanned for further references. Anything else starting with '${'
23
+ that isn't a well-formed reference (${}, ${1BAD}, ${A B}, an unterminated
24
+ ${VAR) is a hard error, consistent with this tool's fail-fast posture
25
+ elsewhere (missing config files, unknown config keys, unsupported flags).
26
+
27
+ Mapping keys are never interpolated, only values.
28
+ """
29
+
30
+ import os
31
+ import re
32
+ from collections.abc import Mapping
33
+ from typing import Any
34
+
35
+ from squarepeg.errors import ConfigError
36
+
37
+ _TOKEN_RE = re.compile(
38
+ r"""
39
+ (?P<escape>\$\$)
40
+ | \$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?::-(?P<default>[^}]*))?\}
41
+ | (?P<bad>\$\{[^}]*\}?)
42
+ """,
43
+ re.VERBOSE,
44
+ )
45
+
46
+
47
+ def interpolate_string(value: str, *, source: str, path: str, env: Mapping[str, str]) -> str:
48
+ def replace(match: re.Match) -> str:
49
+ if match.group("escape") is not None:
50
+ return "$"
51
+ if match.group("bad") is not None:
52
+ raise ConfigError(
53
+ f"malformed reference {match.group('bad')!r} in {path!r} in {source}; "
54
+ "expected ${VAR} or ${VAR:-default}"
55
+ )
56
+ name = match.group("name")
57
+ default = match.group("default")
58
+ if default is not None:
59
+ resolved = env.get(name)
60
+ return resolved if resolved else default
61
+ try:
62
+ return env[name]
63
+ except KeyError:
64
+ raise ConfigError(
65
+ f"environment variable {name!r} is not set, referenced by {path!r} in {source}. "
66
+ f"Use ${{{name}:-default}} to supply a fallback."
67
+ ) from None
68
+
69
+ return _TOKEN_RE.sub(replace, value)
70
+
71
+
72
+ def interpolate_value(value: Any, *, source: str, path: str, env: Mapping[str, str]) -> Any:
73
+ if isinstance(value, str):
74
+ return interpolate_string(value, source=source, path=path, env=env)
75
+ if isinstance(value, Mapping):
76
+ return {
77
+ key: interpolate_value(child, source=source, path=f"{path}.{key}" if path else str(key), env=env)
78
+ for key, child in value.items()
79
+ }
80
+ if isinstance(value, list):
81
+ return [
82
+ interpolate_value(child, source=source, path=f"{path}[{i}]", env=env) for i, child in enumerate(value)
83
+ ]
84
+ return value
85
+
86
+
87
+ def interpolate_document(doc: dict, source: str, env: Mapping[str, str] | None = None) -> dict:
88
+ """Walk a parsed config document, substituting ${VAR}/${VAR:-default} in every string leaf."""
89
+ return interpolate_value(doc, source=source, path="", env=os.environ if env is None else env)
squarepeg/errors.py ADDED
@@ -0,0 +1,34 @@
1
+ class SquarepegError(Exception):
2
+ """Base class for all squarepeg errors that map to a process exit code."""
3
+
4
+ exit_code = 1
5
+
6
+
7
+ class UsageError(SquarepegError):
8
+ """Bad CLI input: malformed flag value, unsupported flag, etc."""
9
+
10
+ exit_code = 2
11
+
12
+
13
+ class UnsupportedFlagError(UsageError):
14
+ pass
15
+
16
+
17
+ class ConfigError(SquarepegError):
18
+ """Config file missing, malformed, or invalid."""
19
+
20
+ exit_code = 2
21
+
22
+
23
+ class RunnerError(SquarepegError):
24
+ """The tool itself failed to run the container to completion."""
25
+
26
+ exit_code = 125
27
+
28
+
29
+ class ApiError(RunnerError):
30
+ """A Kubernetes API call failed; wraps kubernetes.client.ApiException."""
31
+
32
+
33
+ class InterruptError(SquarepegError):
34
+ exit_code = 130
File without changes
@@ -0,0 +1,80 @@
1
+ """Shared Kubernetes API-error interpretation and the delete primitive.
2
+
3
+ Split out of runner.py so both the per-run cleanup path and the orphan sweep
4
+ (squarepeg/k8s/orphans.py) can share the exact same error wrapping and 404
5
+ tolerance, rather than risking two copies drifting apart.
6
+ """
7
+
8
+ import json
9
+
10
+ from kubernetes import client
11
+ from kubernetes.client.rest import ApiException
12
+
13
+ from squarepeg.errors import ApiError
14
+
15
+ API_ERROR_HINTS = {
16
+ 400: "the apiserver rejected the manifest as malformed; run with --dry-run to inspect it",
17
+ 401: "authentication failed; try 'kubectl get pods' to check your credentials",
18
+ 403: "permission denied; check with 'kubectl auth can-i create pods'",
19
+ 404: "not found; check the namespace exists",
20
+ 409: "already exists; omit --name, or clean up a --keep'd previous run",
21
+ 422: "the apiserver rejected the manifest; run with --dry-run to inspect it",
22
+ }
23
+
24
+ # A ResourceQuota admission rejection also arrives as HTTP 403, but it has nothing to do with
25
+ # RBAC permissions -- the generic 403 hint above ("check with kubectl auth can-i") is actively
26
+ # misleading here. Detected by the admission plugin's standard "failed quota: ..." message
27
+ # text, which names the missing requests/limits per container.
28
+ QUOTA_ERROR_MARKER = "failed quota"
29
+ QUOTA_HINT = (
30
+ "the target namespace enforces a ResourceQuota requiring cpu/memory requests (and "
31
+ "possibly limits) on every container; set --cpus/--memory (or --request-cpu/--request-"
32
+ "memory/--limit-cpu/--limit-memory), or 'defaults.cpus'/'defaults.memory' in config so "
33
+ "you don't have to pass them on every run"
34
+ )
35
+
36
+
37
+ def api_exception_detail(exc: ApiException) -> str:
38
+ """Extract the Kubernetes Status object's 'message' field from the response body, if
39
+ present -- this is almost always far more specific than exc.reason (the bare HTTP
40
+ reason phrase, e.g. 'Bad Request'), naming the exact field the apiserver rejected."""
41
+ if exc.body:
42
+ try:
43
+ body = json.loads(exc.body)
44
+ except (TypeError, ValueError):
45
+ body = None
46
+ if isinstance(body, dict) and body.get("message"):
47
+ return body["message"]
48
+ return exc.reason
49
+
50
+
51
+ def wrap_api_exception(exc: ApiException, action: str) -> ApiError:
52
+ detail = api_exception_detail(exc)
53
+ if exc.status == 403 and QUOTA_ERROR_MARKER in detail.lower():
54
+ hint = QUOTA_HINT
55
+ else:
56
+ hint = API_ERROR_HINTS.get(exc.status)
57
+ message = f"{action}: {detail}"
58
+ if hint:
59
+ message += f" ({hint})"
60
+ return ApiError(message)
61
+
62
+
63
+ def delete_resource(session, kind: str, name: str) -> bool:
64
+ """Delete a Pod or Job by name, tolerating 404 (already gone).
65
+
66
+ Returns True if the delete actually removed something, False if it was already gone.
67
+ Raises ApiError for any other failure.
68
+ """
69
+ try:
70
+ if kind == "job":
71
+ session.batch.delete_namespaced_job(
72
+ name, session.namespace, body=client.V1DeleteOptions(propagation_policy="Background")
73
+ )
74
+ else:
75
+ session.core.delete_namespaced_pod(name, session.namespace)
76
+ except ApiException as exc:
77
+ if exc.status == 404:
78
+ return False
79
+ raise wrap_api_exception(exc, f"failed to delete {kind} {name!r}") from exc
80
+ return True
@@ -0,0 +1,47 @@
1
+ """Submit a manifest to the apiserver as a server-side dry run (nothing is persisted).
2
+
3
+ Deliberately separate from runner.py: that module's concern is the full create/stream/wait/
4
+ cleanup lifecycle of a resource that actually exists. Server-side dry-run shares none of
5
+ that -- it's a single validate-and-discard apiserver call.
6
+ """
7
+
8
+ from kubernetes import client
9
+ from kubernetes.client.rest import ApiException
10
+
11
+ from squarepeg.k8s.apierrors import wrap_api_exception
12
+ from squarepeg.runspec import RunSpec
13
+
14
+ SERVER_DRY_RUN = "All" # the only value the Kubernetes API accepts
15
+
16
+ _SERIALIZER: client.ApiClient | None = None
17
+
18
+
19
+ def _api_client() -> client.ApiClient:
20
+ global _SERIALIZER
21
+ if _SERIALIZER is None:
22
+ _SERIALIZER = client.ApiClient()
23
+ return _SERIALIZER
24
+
25
+
26
+ def _to_plain_dict(response):
27
+ if isinstance(response, dict):
28
+ return response
29
+ return _api_client().sanitize_for_serialization(response)
30
+
31
+
32
+ def server_dry_run(session, spec: RunSpec, manifest: dict) -> dict:
33
+ """Submit `manifest` to the apiserver with dryRun=All and return its response as a plain dict.
34
+
35
+ Runs full validation, defaulting, and admission/mutating webhooks; nothing is persisted.
36
+ Raises ApiError (via wrap_api_exception) on rejection, reusing the same hints as a real
37
+ create failure.
38
+ """
39
+ name = manifest["metadata"]["name"]
40
+ try:
41
+ if spec.mode == "job":
42
+ response = session.batch.create_namespaced_job(session.namespace, manifest, dry_run=SERVER_DRY_RUN)
43
+ else:
44
+ response = session.core.create_namespaced_pod(session.namespace, manifest, dry_run=SERVER_DRY_RUN)
45
+ except ApiException as exc:
46
+ raise wrap_api_exception(exc, f"failed to validate {spec.mode} {name!r} against the apiserver") from exc
47
+ return _to_plain_dict(response)
@@ -0,0 +1,19 @@
1
+ """Surface pod events as diagnostics: the difference between 'it hung' and
2
+ '0/12 nodes available: insufficient memory'.
3
+ """
4
+
5
+ from kubernetes.client.rest import ApiException
6
+
7
+ from squarepeg.log import chatter
8
+
9
+
10
+ def print_pod_events(session, pod_name: str, *, quiet: bool = False) -> None:
11
+ try:
12
+ events = session.core.list_namespaced_event(
13
+ session.namespace, field_selector=f"involvedObject.name={pod_name}"
14
+ )
15
+ except ApiException:
16
+ return # best-effort diagnostics; never fail the run because event listing failed
17
+
18
+ for event in events.items or []:
19
+ chatter(f"{pod_name}: {event.reason}: {event.message}", quiet=quiet)
squarepeg/k8s/logs.py ADDED
@@ -0,0 +1,126 @@
1
+ """Follow a pod's logs and write them to stdout, reconnecting on stream drops.
2
+
3
+ The kubernetes log API only accepts `since_seconds` (not `since_time`), so a
4
+ reconnect can only ask for "everything from roughly N seconds ago" -- some
5
+ overlap with what was already printed is unavoidable. We request timestamps
6
+ internally, strip them before writing (so the user's stdout stays byte-clean
7
+ unless --timestamps was passed), and suppress any re-delivered line whose
8
+ timestamp is <= the last one we already emitted, breaking ties within the
9
+ same timestamp with a small per-timestamp set of already-seen line hashes.
10
+ """
11
+
12
+ import math
13
+ import re
14
+ import time
15
+ from datetime import datetime, timezone
16
+
17
+ from kubernetes.client.rest import ApiException
18
+
19
+ from squarepeg.errors import ApiError
20
+ from squarepeg.log import chatter
21
+
22
+ _TIMESTAMP_RE = re.compile(rb"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) (.*)$", re.DOTALL)
23
+
24
+ MAX_RECONNECT_ATTEMPTS = 5
25
+ MAX_BACKOFF_SECONDS = 30
26
+
27
+
28
+ def split_timestamp(raw_line: bytes) -> tuple[str | None, bytes]:
29
+ match = _TIMESTAMP_RE.match(raw_line)
30
+ if not match:
31
+ return None, raw_line
32
+ ts, content = match.groups()
33
+ return ts.decode(), content
34
+
35
+
36
+ def _parse_rfc3339(ts: str) -> datetime:
37
+ if ts.endswith("Z"):
38
+ ts = ts[:-1] + "+00:00"
39
+ return datetime.fromisoformat(ts)
40
+
41
+
42
+ def since_seconds_from(last_ts: str, now: datetime | None = None) -> int:
43
+ now = now or datetime.now(timezone.utc)
44
+ elapsed = (now - _parse_rfc3339(last_ts)).total_seconds()
45
+ return max(1, math.ceil(elapsed) + 1)
46
+
47
+
48
+ class _Dedupe:
49
+ """Suppress log lines already emitted, tolerating out-of-order redelivery on reconnect."""
50
+
51
+ def __init__(self):
52
+ self.last_ts: str | None = None
53
+ self._seen_at_last_ts: set[bytes] = set()
54
+
55
+ def should_emit(self, ts: str | None, content: bytes) -> bool:
56
+ if ts is None:
57
+ return True
58
+ if self.last_ts is None or ts > self.last_ts:
59
+ self.last_ts = ts
60
+ self._seen_at_last_ts = {content}
61
+ return True
62
+ if ts < self.last_ts:
63
+ return False
64
+ if content in self._seen_at_last_ts:
65
+ return False
66
+ self._seen_at_last_ts.add(content)
67
+ return True
68
+
69
+
70
+ def _open_stream(session, pod_name, container_name, since_seconds):
71
+ try:
72
+ return session.core.read_namespaced_pod_log(
73
+ name=pod_name,
74
+ namespace=session.namespace,
75
+ container=container_name,
76
+ follow=True,
77
+ timestamps=True,
78
+ since_seconds=since_seconds,
79
+ _preload_content=False,
80
+ )
81
+ except ApiException as exc:
82
+ raise ApiError(f"failed to open log stream for pod {pod_name!r}: {exc.reason}") from exc
83
+
84
+
85
+ def stream_logs(session, pod_name: str, container_name: str, stop_event, *, quiet: bool = False, sink=None) -> None:
86
+ """Follow the pod's logs, writing raw bytes to `sink` (default sys.stdout.buffer).
87
+
88
+ Returns when the stream ends normally (the pod is terminal) or `stop_event` is set.
89
+ Reconnects on a dropped stream, giving up after MAX_RECONNECT_ATTEMPTS with a warning.
90
+ """
91
+ if sink is None:
92
+ import sys
93
+
94
+ sink = sys.stdout.buffer
95
+
96
+ dedupe = _Dedupe()
97
+ since_seconds = None
98
+ attempt = 0
99
+
100
+ while not stop_event.is_set():
101
+ response = _open_stream(session, pod_name, container_name, since_seconds)
102
+ try:
103
+ for raw_line in response:
104
+ if stop_event.is_set():
105
+ return
106
+ ts, content = split_timestamp(raw_line)
107
+ if dedupe.should_emit(ts, content):
108
+ sink.write(content if content.endswith(b"\n") else content + b"\n")
109
+ sink.flush()
110
+ return # clean EOF: the stream ended on its own
111
+ except Exception as exc: # any transport error triggers a reconnect attempt
112
+ attempt += 1
113
+ if attempt > MAX_RECONNECT_ATTEMPTS:
114
+ chatter(
115
+ f"log stream for pod {pod_name!r} failed after {attempt - 1} reconnect attempts ({exc}); "
116
+ "still waiting for the pod to finish",
117
+ quiet=quiet,
118
+ )
119
+ return
120
+ if dedupe.last_ts is not None:
121
+ since_seconds = since_seconds_from(dedupe.last_ts)
122
+ backoff = min(2**attempt, MAX_BACKOFF_SECONDS)
123
+ chatter(f"log stream for pod {pod_name!r} dropped, reconnecting in {backoff}s", quiet=quiet)
124
+ time.sleep(backoff)
125
+ finally:
126
+ response.close()
@@ -0,0 +1,198 @@
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
+
24
+ from datetime import datetime, timezone
25
+
26
+ from kubernetes.client.rest import ApiException
27
+
28
+ from squarepeg.errors import ApiError
29
+ from squarepeg.k8s.apierrors import delete_resource, wrap_api_exception
30
+ from squarepeg.k8s.podstate import (
31
+ TERMINAL_PHASES,
32
+ job_terminal_age_seconds,
33
+ pod_age_seconds,
34
+ stuck_waiting_reason,
35
+ terminal_age_seconds,
36
+ )
37
+ from squarepeg.labels import (
38
+ CREATED_BY_LABEL,
39
+ KEEP_LABEL,
40
+ MANAGED_BY_LABEL,
41
+ MANAGED_BY_VALUE,
42
+ RUN_ID_LABEL,
43
+ current_created_by,
44
+ )
45
+ from squarepeg.log import chatter
46
+ from squarepeg.runspec import RunSpec
47
+
48
+ DEFAULT_MIN_AGE_SECONDS = 300
49
+
50
+
51
+ def _label_selector(created_by: str) -> str:
52
+ return f"{MANAGED_BY_LABEL}={MANAGED_BY_VALUE},{CREATED_BY_LABEL}={created_by},!{KEEP_LABEL}"
53
+
54
+
55
+ def _job_name_for_pod(pod) -> str | None:
56
+ labels = pod.metadata.labels or {}
57
+ if "job-name" in labels:
58
+ return labels["job-name"]
59
+ for ref in pod.metadata.owner_references or []:
60
+ if ref.kind == "Job":
61
+ return ref.name
62
+ return None
63
+
64
+
65
+ def _is_own_and_unexcluded(labels: dict, created_by: str, exclude_run_id: str | None) -> bool:
66
+ if labels.get(CREATED_BY_LABEL) != created_by:
67
+ return False
68
+ if exclude_run_id is not None and labels.get(RUN_ID_LABEL) == exclude_run_id:
69
+ return False
70
+ # belt-and-braces: the server-side selector already excludes anything carrying the keep
71
+ # label, but re-checking client-side means a selector typo can never turn into deleting
72
+ # a resource the user explicitly asked to keep.
73
+ if labels.get(KEEP_LABEL):
74
+ return False
75
+ return True
76
+
77
+
78
+ def _sweepable_pod_age(pod, min_age_seconds: int, now: datetime) -> bool:
79
+ """True if this pod (already confirmed own-user/not-excluded) is old enough to sweep,
80
+ under either the terminal or the stuck-before-starting category."""
81
+ phase = pod.status.phase
82
+ if phase in TERMINAL_PHASES:
83
+ age = terminal_age_seconds(pod, now)
84
+ elif phase == "Pending" and stuck_waiting_reason(pod) is not None:
85
+ age = pod_age_seconds(pod, now)
86
+ else:
87
+ return False
88
+ return age is not None and age >= min_age_seconds
89
+
90
+
91
+ def _find_pods(pods, created_by: str, min_age_seconds: int, exclude_run_id: str | None, now: datetime):
92
+ """Returns (bare_pod_candidates, stuck_child_job_names).
93
+
94
+ Job-owned pods are never deleted directly -- deleting a Job's child pod while the Job
95
+ still has a non-zero backoffLimit would make the controller spawn a replacement, i.e.
96
+ the sweep would start work rather than clean it up. Instead, a Job-owned pod that would
97
+ otherwise qualify is reported back so the caller can delete the parent Job instead,
98
+ which cascades to the pod via Background propagation.
99
+ """
100
+ bare_candidates = []
101
+ stuck_child_job_names = set()
102
+
103
+ for pod in pods:
104
+ labels = pod.metadata.labels or {}
105
+ if not _is_own_and_unexcluded(labels, created_by, exclude_run_id):
106
+ continue
107
+ if not _sweepable_pod_age(pod, min_age_seconds, now):
108
+ continue
109
+
110
+ job_name = _job_name_for_pod(pod)
111
+ if job_name is not None:
112
+ stuck_child_job_names.add(job_name)
113
+ else:
114
+ bare_candidates.append(("pod", pod.metadata.name))
115
+
116
+ return bare_candidates, stuck_child_job_names
117
+
118
+
119
+ def _find_jobs(
120
+ jobs, created_by: str, min_age_seconds: int, exclude_run_id: str | None, now: datetime, stuck_child_job_names: set
121
+ ):
122
+ candidates = []
123
+ for job in jobs:
124
+ labels = job.metadata.labels or {}
125
+ if not _is_own_and_unexcluded(labels, created_by, exclude_run_id):
126
+ continue
127
+
128
+ name = job.metadata.name
129
+ age = job_terminal_age_seconds(job, now)
130
+ if age is not None and age >= min_age_seconds:
131
+ candidates.append(("job", name))
132
+ elif name in stuck_child_job_names:
133
+ # no Complete/Failed condition yet (e.g. backoffLimit: 0 means a stuck pull
134
+ # never produces a failure condition at all), but its child pod has been
135
+ # stuck on a fail-fast reason long enough -- same verdict, different signal.
136
+ candidates.append(("job", name))
137
+ return candidates
138
+
139
+
140
+ def find_orphaned_resources(
141
+ session,
142
+ created_by: str,
143
+ min_age_seconds: int,
144
+ *,
145
+ exclude_run_id: str | None = None,
146
+ now: datetime | None = None,
147
+ ) -> list[tuple[str, str]]:
148
+ """Discover this user's own orphaned Pods/Jobs.
149
+
150
+ Server-side scoped to app.kubernetes.io/managed-by=squarepeg, squarepeg.io/created-by
151
+ matching `created_by`, and NOT carrying squarepeg.io/keep -- so a teammate's resource or
152
+ a deliberately --keep'd one is never even returned by the apiserver. Client-side filtered
153
+ by phase/waiting-reason and age. Returns [(kind, name), ...]; deletion is identical for
154
+ every entry regardless of which category matched.
155
+
156
+ `now` defaults to the real current time; tests pass a fixed value.
157
+ """
158
+ now = now or datetime.now(timezone.utc)
159
+ selector = _label_selector(created_by)
160
+
161
+ pods = session.core.list_namespaced_pod(session.namespace, label_selector=selector).items
162
+ jobs = session.batch.list_namespaced_job(session.namespace, label_selector=selector).items
163
+
164
+ bare_pod_candidates, stuck_child_job_names = _find_pods(pods, created_by, min_age_seconds, exclude_run_id, now)
165
+ job_candidates = _find_jobs(jobs, created_by, min_age_seconds, exclude_run_id, now, stuck_child_job_names)
166
+ return bare_pod_candidates + job_candidates
167
+
168
+
169
+ def sweep_orphans(
170
+ session, spec: RunSpec, *, exclude_run_id: str | None = None, now: datetime | None = None
171
+ ) -> int:
172
+ """Discover and delete this user's own orphaned resources.
173
+
174
+ Best-effort throughout: a per-resource delete failure is logged and skipped rather than
175
+ raised, so one undeletable orphan never aborts the rest of the sweep. The caller
176
+ (run_manifest) is responsible for making sure a failure here can never change the
177
+ current invocation's own exit code -- see runner.py's finally block.
178
+ """
179
+ created_by = current_created_by()
180
+ try:
181
+ candidates = find_orphaned_resources(
182
+ session, created_by, spec.orphan_sweep_min_age, exclude_run_id=exclude_run_id, now=now
183
+ )
184
+ except ApiException as exc:
185
+ wrapped = wrap_api_exception(exc, "failed to list resources for orphan sweep")
186
+ raise ApiError(f"{wrapped} (set 'orphan_sweep: false' in config to disable)") from exc
187
+
188
+ deleted = 0
189
+ for kind, name in candidates:
190
+ try:
191
+ if delete_resource(session, kind, name):
192
+ deleted += 1
193
+ except Exception as exc: # one bad delete must not abort the rest of the sweep
194
+ chatter(f"orphan sweep: failed to delete {kind} {name!r}: {exc}", quiet=spec.quiet)
195
+
196
+ if deleted:
197
+ chatter(f"swept {deleted} orphaned resource(s) from previous runs", quiet=spec.quiet)
198
+ return deleted