mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
374 lines
15 KiB
Python
374 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Pod management functions."""
|
|
|
|
import sys
|
|
import json
|
|
import time
|
|
import subprocess
|
|
from datetime import datetime, timedelta
|
|
|
|
from toolbox.kubernetes import kubectl_cmd
|
|
|
|
|
|
class ClaimRaceError(Exception):
|
|
"""Raised when a claim attempt loses an optimistic-concurrency race.
|
|
|
|
The Kubernetes API rejects label/annotate writes whose ``--resource-version``
|
|
does not match the current value, returning a 409 Conflict. ``claim_pod``
|
|
surfaces that as ``ClaimRaceError`` so the caller can retry against a
|
|
different pod (or bail) instead of silently overwriting another writer.
|
|
"""
|
|
|
|
|
|
def _is_conflict(stderr: str) -> bool:
|
|
"""Heuristic detection of a 409 Conflict in kubectl stderr."""
|
|
return "Conflict" in stderr or "the object has been modified" in stderr
|
|
|
|
|
|
def get_toolbox_pod(
|
|
user: str,
|
|
check_claimed: bool = True,
|
|
*,
|
|
app_label: str,
|
|
claimed_label_key: str,
|
|
namespace: str,
|
|
context: str | None = None,
|
|
extra_selector: str | None = None,
|
|
) -> tuple[str, bool, str]:
|
|
"""Get an available toolbox pod name and the resourceVersion observed.
|
|
|
|
Args:
|
|
user: Current user ARN
|
|
check_claimed: If True, first look for pods already claimed by the user
|
|
app_label: Value of the app.kubernetes.io/name label that identifies the pool.
|
|
claimed_label_key: Label key the wrapper sets on a claimed pod.
|
|
namespace: Kubernetes namespace the pool lives in.
|
|
context: Optional kubernetes context to scope the kubectl call to.
|
|
extra_selector: Optional additional label selector ANDed onto
|
|
``app.kubernetes.io/name=<app_label>``. Used when other resources
|
|
in the namespace share the same name label (e.g. the golden chart
|
|
puts the toolbox's own pgbouncer pods under the same name) so we
|
|
need a second key like ``app.kubernetes.io/component=app`` to
|
|
pick out only the main pool.
|
|
|
|
Returns:
|
|
Tuple of (pod_name, is_already_claimed, resource_version). The
|
|
resource_version reflects the pod state at observation time and is
|
|
used by ``claim_pod`` for optimistic concurrency control.
|
|
"""
|
|
label_selector = f"app.kubernetes.io/name={app_label}"
|
|
if extra_selector:
|
|
label_selector = f"{label_selector},{extra_selector}"
|
|
wait_start = datetime.now()
|
|
max_wait = timedelta(minutes=5)
|
|
update_interval = timedelta(seconds=30)
|
|
next_update = wait_start
|
|
|
|
while True:
|
|
try:
|
|
result = subprocess.run(
|
|
kubectl_cmd(
|
|
"get",
|
|
"pods",
|
|
"-n",
|
|
namespace,
|
|
"-l",
|
|
label_selector,
|
|
"-o",
|
|
"json",
|
|
context=context,
|
|
),
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
pods = json.loads(result.stdout)["items"]
|
|
|
|
if check_claimed:
|
|
# Get user ID from ARN and sanitize it
|
|
user_id = sanitize_label(user.split("/")[-1] if "/" in user else user)
|
|
# First look for pods already claimed by this user
|
|
claimed_pods = [
|
|
(pod["metadata"]["name"], pod["metadata"].get("resourceVersion", ""))
|
|
for pod in pods
|
|
if pod["status"]["phase"] == "Running"
|
|
and pod.get("metadata", {}).get("labels", {}).get(claimed_label_key) == user_id
|
|
and not pod.get("metadata", {}).get("deletionTimestamp")
|
|
]
|
|
|
|
if claimed_pods:
|
|
print("⚠️ Found pod already claimed by you") # noqa: T201
|
|
return claimed_pods[0][0], True, claimed_pods[0][1]
|
|
print("No pods currently claimed by you, looking for available pods...") # noqa: T201
|
|
|
|
# Look for unclaimed pods
|
|
available_pods = [
|
|
(pod["metadata"]["name"], pod["metadata"].get("resourceVersion", ""))
|
|
for pod in pods
|
|
if pod["status"]["phase"] in ("Running", "Pending")
|
|
and claimed_label_key not in pod.get("metadata", {}).get("labels", {})
|
|
and "terminate-after" not in pod.get("metadata", {}).get("labels", {})
|
|
and not pod.get("metadata", {}).get("deletionTimestamp")
|
|
]
|
|
|
|
if available_pods:
|
|
return available_pods[0][0], False, available_pods[0][1]
|
|
|
|
# No pods available, check if we should wait or timeout
|
|
current_time = datetime.now()
|
|
if current_time - wait_start > max_wait:
|
|
print("\n❌ No pods became available after 5 minutes.") # noqa: T201
|
|
print("Please reach out to #team-infrastructure for assistance.") # noqa: T201
|
|
sys.exit(1)
|
|
|
|
# Check if we should print an update
|
|
if current_time >= next_update:
|
|
wait_time = current_time - wait_start
|
|
print(f"⏳ Waiting for available pod... ({int(wait_time.total_seconds())}s)") # noqa: T201
|
|
next_update = current_time + update_interval
|
|
|
|
# Wait before next check
|
|
time.sleep(2)
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"kubectl failed: {e.stderr or e}") # noqa: T201
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Unexpected error getting toolbox pod: {e}") # noqa: T201
|
|
sys.exit(1)
|
|
|
|
|
|
def claim_pod(
|
|
pod_name: str,
|
|
user_labels: dict,
|
|
timestamp: int,
|
|
*,
|
|
namespace: str,
|
|
context: str | None = None,
|
|
resource_version: str | None = None,
|
|
):
|
|
"""Claim the pod by updating its labels.
|
|
|
|
When ``resource_version`` is supplied, the first kubectl call that mutates
|
|
the pod attaches ``--resource-version=<rv>`` so the API rejects the write
|
|
if another user has already modified the pod since we observed it. On
|
|
rejection, ``ClaimRaceError`` is raised so the caller can retry against a
|
|
different pod. Subsequent kubectl calls in the same claim sequence do not
|
|
pass --resource-version because the resource version advances after every
|
|
successful write; once the first mutation has won, no other writer can
|
|
sneak in mid-claim.
|
|
|
|
Strips every label except app.kubernetes.io/name (which orphans the pod from
|
|
its ReplicaSet by removing pod-template-hash), annotates karpenter to skip
|
|
disruption, then applies user_labels + a terminate-after timestamp.
|
|
"""
|
|
human_readable = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
print(f"Claiming pod {pod_name} until {human_readable}") # noqa: T201
|
|
try:
|
|
result = subprocess.run(
|
|
kubectl_cmd("get", "pod", "-n", namespace, pod_name, "-o", "jsonpath={.metadata.labels}", context=context),
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
|
|
current_labels = json.loads(result.stdout)
|
|
|
|
labels_to_remove = [name for name in current_labels.keys() if name != "app.kubernetes.io/name"]
|
|
# Attach the optimistic-concurrency guard to whichever kubectl invocation is the first
|
|
# actual write; once it succeeds, the resourceVersion advances and subsequent calls
|
|
# don't need (and can't pass) the same value.
|
|
rv_guard_pending = bool(resource_version)
|
|
|
|
if labels_to_remove:
|
|
args = [f"{name}-" for name in labels_to_remove]
|
|
if rv_guard_pending:
|
|
args.append(f"--resource-version={resource_version}")
|
|
rv_guard_pending = False
|
|
try:
|
|
subprocess.run(
|
|
kubectl_cmd("label", "pod", "-n", namespace, pod_name, *args, context=context),
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
if _is_conflict(e.stderr or ""):
|
|
raise ClaimRaceError(f"Pod {pod_name} was claimed by another writer") from e
|
|
print(f"Error stripping labels: {e.stderr or e}") # noqa: T201
|
|
raise
|
|
|
|
annotate_args = ["karpenter.sh/do-not-disrupt=true", "--overwrite=true"]
|
|
if rv_guard_pending:
|
|
annotate_args.append(f"--resource-version={resource_version}")
|
|
rv_guard_pending = False
|
|
try:
|
|
subprocess.run(
|
|
kubectl_cmd("annotate", "pod", "-n", namespace, pod_name, *annotate_args, context=context),
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
if _is_conflict(e.stderr or ""):
|
|
raise ClaimRaceError(f"Pod {pod_name} was claimed by another writer") from e
|
|
print(f"Error annotating pod: {e.stderr or e}") # noqa: T201
|
|
raise
|
|
|
|
label_args = [f"{key}={value}" for key, value in {**user_labels, "terminate-after": str(timestamp)}.items()]
|
|
subprocess.run(
|
|
kubectl_cmd("label", "pod", "-n", namespace, pod_name, *label_args, context=context),
|
|
check=True,
|
|
)
|
|
|
|
print("⏳ Waiting for pod to be ready") # noqa: T201
|
|
try:
|
|
subprocess.run(
|
|
kubectl_cmd(
|
|
"wait",
|
|
"--for=condition=Ready",
|
|
"--timeout=5m",
|
|
"-n",
|
|
namespace,
|
|
"pod",
|
|
pod_name,
|
|
context=context,
|
|
),
|
|
check=True,
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
print(f"❌ Pod {pod_name} did not become ready within 5 minutes.") # noqa: T201
|
|
sys.exit(1)
|
|
except ClaimRaceError:
|
|
raise
|
|
except Exception as e:
|
|
print(f"Error claiming pod: {e}") # noqa: T201
|
|
sys.exit(1)
|
|
|
|
|
|
def connect_to_pod(pod_name: str, *, namespace: str, context: str | None = None) -> int:
|
|
"""Connect to the specified pod using kubectl exec, returning the exec exit code.
|
|
|
|
The caller is responsible for surfacing a non-zero return as a non-zero
|
|
process exit so failures (RBAC denied, pod gone, network) don't get masked
|
|
as success and trigger downstream cleanup of a session that never opened.
|
|
"""
|
|
_safe_print(f"🚀 Connecting to pod {pod_name}...")
|
|
result = subprocess.run(kubectl_cmd("exec", "-it", "-n", namespace, pod_name, "--", "bash", context=context))
|
|
return result.returncode
|
|
|
|
|
|
def _safe_print(msg: str) -> None:
|
|
"""print() that swallows IOErrors raised by writing to a hung-up terminal.
|
|
|
|
When the user closes their terminal tab, the wrapper receives SIGHUP and
|
|
runs cleanup via atexit *after* the controlling PTY is gone. A naked
|
|
``print()`` in that path raises ``BrokenPipeError``/``OSError`` and aborts
|
|
the cleanup mid-flight — leaking the very pod we're trying to delete. The
|
|
actual cluster-side work (kubectl delete) is the load-bearing call;
|
|
progress messages are best-effort.
|
|
"""
|
|
try:
|
|
print(msg) # noqa: T201
|
|
except (BrokenPipeError, OSError):
|
|
pass
|
|
|
|
|
|
def delete_pod(
|
|
pod_name: str,
|
|
*,
|
|
namespace: str,
|
|
context: str | None = None,
|
|
auto_yes: bool = False,
|
|
expected_label_key: str | None = None,
|
|
expected_label_value: str | None = None,
|
|
):
|
|
"""Delete the specified pod.
|
|
|
|
With auto_yes=True, skip the [y/N] prompt and unconditionally delete.
|
|
Uses --ignore-not-found so it is safe to call on a pod whose claim labels
|
|
were never written or that has already been deleted.
|
|
|
|
When ``expected_label_key`` and ``expected_label_value`` are supplied, the
|
|
pod's current ``<key>`` label is checked first; if it does not match, the
|
|
delete is skipped. This guards the atexit path from deleting a healthy
|
|
unclaimed pool pod when ``claim_pod()`` failed before writing the claim
|
|
label (e.g. a transient kubectl/get failure on the very first call). If
|
|
the label check itself errors, the safe default is to skip the delete —
|
|
leaving an orphan to be reaped is preferable to silently shrinking the pool.
|
|
|
|
Robust to a closed/hung-up controlling terminal: progress prints can fail
|
|
silently, and kubectl's own stdout/stderr are captured so they can't trip
|
|
on the same broken FDs. The kubectl delete is the load-bearing operation
|
|
and runs even when the user's terminal is gone.
|
|
"""
|
|
if expected_label_key is not None and expected_label_value is not None:
|
|
try:
|
|
result = subprocess.run(
|
|
kubectl_cmd(
|
|
"get",
|
|
"pod",
|
|
"-n",
|
|
namespace,
|
|
pod_name,
|
|
"-o",
|
|
f"jsonpath={{.metadata.labels.{expected_label_key}}}",
|
|
"--ignore-not-found",
|
|
context=context,
|
|
),
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except subprocess.CalledProcessError as e:
|
|
_safe_print(f"⚠️ Skipping delete of {pod_name}: could not verify claim label ({e.stderr or e})")
|
|
return
|
|
observed = result.stdout.strip()
|
|
if observed != expected_label_value:
|
|
_safe_print(
|
|
f"⚠️ Skipping delete of {pod_name}: "
|
|
f"{expected_label_key}='{observed}' (expected '{expected_label_value}')"
|
|
)
|
|
return
|
|
|
|
if not auto_yes:
|
|
try:
|
|
response = input("\n❓ Would you like to delete this pod now? [y/N]: ").lower().strip()
|
|
except (BrokenPipeError, OSError, EOFError):
|
|
# No usable TTY — default to "don't delete" so we never delete without consent.
|
|
_safe_print("👋 Pod will remain running until its scheduled termination time")
|
|
return
|
|
if response != "y":
|
|
_safe_print("👋 Pod will remain running until its scheduled termination time")
|
|
return
|
|
|
|
_safe_print("🗑️ Deleting pod...")
|
|
try:
|
|
subprocess.run(
|
|
kubectl_cmd(
|
|
"delete",
|
|
"pod",
|
|
"-n",
|
|
namespace,
|
|
pod_name,
|
|
"--ignore-not-found",
|
|
"--wait=false",
|
|
context=context,
|
|
),
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
_safe_print("✅ Pod scheduled for deletion")
|
|
except subprocess.CalledProcessError as e:
|
|
_safe_print(f"❌ Failed to delete pod: {e.stderr or e}")
|
|
|
|
|
|
def sanitize_label(value: str) -> str:
|
|
"""Sanitize a value to be a valid kubernetes label value."""
|
|
# Replace @ with _at_ and keep dots
|
|
sanitized = value.replace("@", "_at_")
|
|
# Ensure it starts and ends with alphanumeric
|
|
sanitized = sanitized.strip("_")
|
|
return sanitized
|