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.
- squarepeg/__init__.py +1 -0
- squarepeg/__main__.py +4 -0
- squarepeg/cli.py +345 -0
- squarepeg/config.py +190 -0
- squarepeg/dockerargs.py +60 -0
- squarepeg/env_interpolation.py +89 -0
- squarepeg/errors.py +34 -0
- squarepeg/k8s/__init__.py +0 -0
- squarepeg/k8s/apierrors.py +80 -0
- squarepeg/k8s/dryrun.py +47 -0
- squarepeg/k8s/events.py +19 -0
- squarepeg/k8s/logs.py +126 -0
- squarepeg/k8s/orphans.py +198 -0
- squarepeg/k8s/podstate.py +93 -0
- squarepeg/k8s/runner.py +233 -0
- squarepeg/k8s/session.py +79 -0
- squarepeg/labels.py +23 -0
- squarepeg/log.py +6 -0
- squarepeg/manifest.py +175 -0
- squarepeg/merge.py +81 -0
- squarepeg/naming.py +55 -0
- squarepeg/quantities.py +58 -0
- squarepeg/runspec.py +41 -0
- squarepeg/volumes.py +112 -0
- squarepeg-0.1.0.dist-info/METADATA +52 -0
- squarepeg-0.1.0.dist-info/RECORD +29 -0
- squarepeg-0.1.0.dist-info/WHEEL +5 -0
- squarepeg-0.1.0.dist-info/entry_points.txt +2 -0
- squarepeg-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
squarepeg/k8s/dryrun.py
ADDED
|
@@ -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)
|
squarepeg/k8s/events.py
ADDED
|
@@ -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()
|
squarepeg/k8s/orphans.py
ADDED
|
@@ -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
|