mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Mechanically re-derived: bin/debrand runs over a fresh import of posthog/posthog@fa1a9a60d4 (2026-08-06). The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md and the squashed initial migration are restored from the shipped tree, same as the previous import. The prune's import-closure guard kept 21 products the new core now imports; dropping them is a refactor now, recorded rather than forced.
1678 lines
69 KiB
Python
1678 lines
69 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import os
|
|
import json
|
|
import time
|
|
import signal
|
|
import subprocess
|
|
import importlib.util
|
|
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from toolbox.kubernetes import (
|
|
check_context_access,
|
|
get_available_contexts,
|
|
get_context_profile,
|
|
get_current_context,
|
|
kubectl_cmd,
|
|
reset_sso,
|
|
select_context,
|
|
select_environment,
|
|
summarize_diagnostic,
|
|
switch_context,
|
|
validate_context,
|
|
wait_for_context_access,
|
|
)
|
|
from toolbox.pod import ClaimRaceError, claim_pod, delete_pod, get_toolbox_pod
|
|
from toolbox.user import get_current_user, parse_arn, sanitize_label
|
|
|
|
# Load toolbox.py (the script with main() and POOLS) by file path, since the
|
|
# `toolbox` package shadows it in normal import resolution.
|
|
_TOOLBOX_SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolbox.py")
|
|
_spec = importlib.util.spec_from_file_location("_toolbox_script", _TOOLBOX_SCRIPT_PATH)
|
|
toolbox_script = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(toolbox_script)
|
|
|
|
|
|
class TestToolbox(unittest.TestCase):
|
|
def test_kubectl_cmd_without_context(self):
|
|
"""kubectl_cmd with context=None returns the same shape as a bare kubectl call."""
|
|
self.assertEqual(kubectl_cmd("get", "pods"), ["kubectl", "get", "pods"])
|
|
|
|
def test_kubectl_cmd_with_context(self):
|
|
"""kubectl_cmd injects --context= so we can scope to a cluster without mutating kubeconfig."""
|
|
self.assertEqual(
|
|
kubectl_cmd("get", "pods", context="insights-dev"),
|
|
["kubectl", "--context=insights-dev", "get", "pods"],
|
|
)
|
|
|
|
def test_kubectl_cmd_empty_context_is_treated_as_unset(self):
|
|
"""An empty string for context means 'no override', matching get_current_context() returning ''."""
|
|
self.assertEqual(kubectl_cmd("get", "pods", context=""), ["kubectl", "get", "pods"])
|
|
|
|
def test_sanitize_label(self):
|
|
"""Test label sanitization function."""
|
|
# Test basic email sanitization
|
|
self.assertEqual(sanitize_label("[email protected]"), "user_at_example.com")
|
|
|
|
# Test with special characters
|
|
self.assertEqual(sanitize_label("[email protected]"), "user.name_at_example.com")
|
|
|
|
# Test with underscores
|
|
self.assertEqual(sanitize_label("[email protected]"), "user_name_at_example.com")
|
|
|
|
# Test with leading/trailing underscores
|
|
self.assertEqual(sanitize_label("[email protected]_"), "user_at_example.com")
|
|
|
|
def test_sanitize_label_truncation(self):
|
|
long_arn = (
|
|
"arn:aws:sts::169684386827:assumed-role/custom-role-name/" + "[email protected]"
|
|
)
|
|
sanitized = sanitize_label(long_arn)
|
|
self.assertTrue(sanitized.startswith("arn_aws_sts__169684386827_assu"))
|
|
self.assertIn("longemail_at_hanzo.ai", sanitized)
|
|
self.assertLessEqual(len(sanitized), 63)
|
|
|
|
def test_parse_arn_valid(self):
|
|
"""Test parsing valid AWS STS ARN."""
|
|
arn = "arn:aws:sts::169684386827:assumed-role/AWSReservedSSO_developers_0847e649a00cc5e7/[email protected]"
|
|
expected = {"toolbox-claimed": "michael.k_at_hanzo.ai", "role-name": "developers", "assumed-role": "true"}
|
|
self.assertEqual(parse_arn(arn, claimed_label_key="toolbox-claimed"), expected)
|
|
|
|
def test_parse_arn_different_role(self):
|
|
"""Test parsing ARN with different role."""
|
|
arn = "arn:aws:sts::169684386827:assumed-role/AWSReservedSSO_admins_0847e649a00cc5e7/[email protected]"
|
|
expected = {"toolbox-claimed": "michael.k_at_hanzo.ai", "role-name": "admins", "assumed-role": "true"}
|
|
self.assertEqual(parse_arn(arn, claimed_label_key="toolbox-claimed"), expected)
|
|
|
|
def test_parse_arn_unexpected_format(self):
|
|
"""Test parsing ARN with unexpected role format."""
|
|
arn = "arn:aws:sts::169684386827:assumed-role/custom-role-name/[email protected]"
|
|
expected = {"toolbox-claimed": "arn_aws_sts__169684386827_assum_e-name_michael.k_at_hanzo.ai"}
|
|
self.assertEqual(parse_arn(arn, claimed_label_key="toolbox-claimed"), expected)
|
|
|
|
def test_parse_arn_jumphost_label_key(self):
|
|
"""Pool-specific claimed_label_key reaches every return path of parse_arn."""
|
|
arn = "arn:aws:sts::169684386827:assumed-role/AWSReservedSSO_developers_0847e649a00cc5e7/[email protected]"
|
|
expected = {
|
|
"flags-jumphost-claimed": "michael.k_at_hanzo.ai",
|
|
"role-name": "developers",
|
|
"assumed-role": "true",
|
|
}
|
|
self.assertEqual(parse_arn(arn, claimed_label_key="flags-jumphost-claimed"), expected)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_current_user(self, mock_run):
|
|
"""Test getting current user from kubectl auth."""
|
|
# Mock kubectl auth whoami response
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = json.dumps(
|
|
{
|
|
"status": {
|
|
"userInfo": {
|
|
"username": "sso-developers",
|
|
"extra": {
|
|
"arn": [
|
|
"arn:aws:sts::169684386827:assumed-role/AWSReservedSSO_developers_0847e649a00cc5e7/[email protected]"
|
|
],
|
|
"sessionName": ["[email protected]"],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
)
|
|
mock_run.return_value = mock_response
|
|
|
|
user_labels = get_current_user(claimed_label_key="toolbox-claimed")
|
|
expected = {"toolbox-claimed": "michael.k_at_hanzo.ai", "role-name": "developers", "assumed-role": "true"}
|
|
self.assertEqual(user_labels, expected)
|
|
mock_run.assert_called_once_with(
|
|
["kubectl", "auth", "whoami", "-o", "json"], capture_output=True, text=True, check=True
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_current_user_with_context(self, mock_run):
|
|
"""get_current_user with context= scopes the whoami call to that context."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = json.dumps(
|
|
{
|
|
"status": {
|
|
"userInfo": {
|
|
"username": "sso-developers",
|
|
"extra": {
|
|
"arn": [
|
|
"arn:aws:sts::169684386827:assumed-role/AWSReservedSSO_developers_0847e649a00cc5e7/[email protected]"
|
|
],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
)
|
|
mock_run.return_value = mock_response
|
|
|
|
get_current_user(claimed_label_key="toolbox-claimed", context="insights-dev")
|
|
mock_run.assert_called_once_with(
|
|
["kubectl", "--context=insights-dev", "auth", "whoami", "-o", "json"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
|
|
@patch("sys.exit")
|
|
@patch("builtins.print")
|
|
@patch("subprocess.run")
|
|
def test_get_current_user_token_expired(self, mock_run, mock_print, mock_exit):
|
|
"""Test getting current user when token has expired and refresh failed."""
|
|
# Mock kubectl auth whoami to raise error with token expiration message
|
|
error = subprocess.CalledProcessError(
|
|
returncode=1,
|
|
cmd=["kubectl", "auth", "whoami", "-o", "json"],
|
|
stderr="Token has expired and refresh failed",
|
|
)
|
|
mock_run.side_effect = error
|
|
|
|
get_current_user(claimed_label_key="toolbox-claimed")
|
|
|
|
mock_run.assert_called_once_with(
|
|
["kubectl", "auth", "whoami", "-o", "json"], capture_output=True, text=True, check=True
|
|
)
|
|
mock_print.assert_any_call(
|
|
"Token has expired and refresh failed, please reauthenticate with `aws sso login --profile=<your-profile>`"
|
|
)
|
|
mock_exit.assert_called_once_with(1)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_toolbox_pod(self, mock_run):
|
|
"""get_toolbox_pod returns (name, claimed, resource_version) for a fresh unclaimed pod."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = json.dumps(
|
|
{
|
|
"items": [
|
|
{
|
|
"metadata": {
|
|
"name": "toolbox-pod-1",
|
|
"resourceVersion": "12345",
|
|
"labels": {
|
|
"app.kubernetes.io/name": "insights-toolbox-django",
|
|
"pod-template-hash": "749c5d8db",
|
|
"hanzo.ai/image": "insights",
|
|
"hanzo.ai/team": "infra",
|
|
"role": "toolbox",
|
|
},
|
|
"deletionTimestamp": None,
|
|
},
|
|
"status": {"phase": "Running"},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
mock_run.return_value = mock_response
|
|
|
|
pod_name, is_claimed, resource_version = get_toolbox_pod(
|
|
"michael.k_at_hanzo.ai",
|
|
app_label="insights-toolbox-django",
|
|
claimed_label_key="toolbox-claimed",
|
|
namespace="insights",
|
|
)
|
|
self.assertEqual(pod_name, "toolbox-pod-1")
|
|
self.assertFalse(is_claimed)
|
|
self.assertEqual(resource_version, "12345")
|
|
mock_run.assert_called_once_with(
|
|
[
|
|
"kubectl",
|
|
"get",
|
|
"pods",
|
|
"-n",
|
|
"insights",
|
|
"-l",
|
|
"app.kubernetes.io/name=insights-toolbox-django",
|
|
"-o",
|
|
"json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_toolbox_pod_with_context(self, mock_run):
|
|
"""get_toolbox_pod with context= passes --context to kubectl get pods."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = json.dumps(
|
|
{
|
|
"items": [
|
|
{
|
|
"metadata": {
|
|
"name": "toolbox-pod-1",
|
|
"resourceVersion": "12345",
|
|
"labels": {"app.kubernetes.io/name": "insights-toolbox-django"},
|
|
"deletionTimestamp": None,
|
|
},
|
|
"status": {"phase": "Running"},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
mock_run.return_value = mock_response
|
|
|
|
get_toolbox_pod(
|
|
"michael.k_at_hanzo.ai",
|
|
app_label="insights-toolbox-django",
|
|
claimed_label_key="toolbox-claimed",
|
|
namespace="insights",
|
|
context="insights-dev",
|
|
)
|
|
mock_run.assert_called_once_with(
|
|
[
|
|
"kubectl",
|
|
"--context=insights-dev",
|
|
"get",
|
|
"pods",
|
|
"-n",
|
|
"insights",
|
|
"-l",
|
|
"app.kubernetes.io/name=insights-toolbox-django",
|
|
"-o",
|
|
"json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_toolbox_pod_extra_selector(self, mock_run):
|
|
"""extra_selector= is ANDed onto the base name= selector passed to kubectl."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = json.dumps(
|
|
{
|
|
"items": [
|
|
{
|
|
"metadata": {
|
|
"name": "toolbox-pod-1",
|
|
"resourceVersion": "12345",
|
|
"labels": {
|
|
"app.kubernetes.io/name": "insights-toolbox-django",
|
|
"app.kubernetes.io/component": "app",
|
|
},
|
|
"deletionTimestamp": None,
|
|
},
|
|
"status": {"phase": "Running"},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
mock_run.return_value = mock_response
|
|
|
|
get_toolbox_pod(
|
|
"michael.k_at_hanzo.ai",
|
|
app_label="insights-toolbox-django",
|
|
claimed_label_key="toolbox-claimed",
|
|
namespace="insights-toolbox-django",
|
|
extra_selector="app.kubernetes.io/component=app",
|
|
)
|
|
mock_run.assert_called_once_with(
|
|
[
|
|
"kubectl",
|
|
"get",
|
|
"pods",
|
|
"-n",
|
|
"insights-toolbox-django",
|
|
"-l",
|
|
"app.kubernetes.io/name=insights-toolbox-django,app.kubernetes.io/component=app",
|
|
"-o",
|
|
"json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_toolbox_pod_returns_existing_claim_with_rv(self, mock_run):
|
|
"""An already-claimed pod returns is_claimed=True and the observed resourceVersion."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = json.dumps(
|
|
{
|
|
"items": [
|
|
{
|
|
"metadata": {
|
|
"name": "toolbox-pod-1",
|
|
"resourceVersion": "55555",
|
|
"labels": {
|
|
"app.kubernetes.io/name": "insights-toolbox-django",
|
|
"toolbox-claimed": "michael.k_at_hanzo.ai",
|
|
},
|
|
"deletionTimestamp": None,
|
|
},
|
|
"status": {"phase": "Running"},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
mock_run.return_value = mock_response
|
|
|
|
pod_name, is_claimed, rv = get_toolbox_pod(
|
|
"michael.k_at_hanzo.ai",
|
|
app_label="insights-toolbox-django",
|
|
claimed_label_key="toolbox-claimed",
|
|
namespace="insights",
|
|
)
|
|
self.assertEqual(pod_name, "toolbox-pod-1")
|
|
self.assertTrue(is_claimed)
|
|
self.assertEqual(rv, "55555")
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod(self, mock_run):
|
|
"""Test claiming a pod."""
|
|
# Mock kubectl get pod response
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps({"app.kubernetes.io/name": "insights-toolbox-django"})
|
|
|
|
# Mock kubectl label and annotate responses
|
|
mock_label_response = MagicMock()
|
|
mock_annotate_response = MagicMock()
|
|
|
|
# Set up the side effect to handle all kubectl calls
|
|
mock_run.side_effect = [
|
|
mock_get_response, # get pod labels
|
|
mock_annotate_response, # add annotation
|
|
mock_label_response, # add new labels
|
|
mock_label_response, # wait for pod
|
|
]
|
|
|
|
user_labels = {"toolbox-claimed": "michael.k_at_hanzo.ai", "role-name": "developers", "assumed-role": "true"}
|
|
claim_pod("toolbox-pod-1", user_labels, 1234567890, namespace="insights")
|
|
|
|
# Verify kubectl commands were called correctly
|
|
self.assertEqual(mock_run.call_count, 4)
|
|
|
|
# Get all calls made to kubectl
|
|
calls = mock_run.call_args_list
|
|
|
|
# Verify the first call to get pod labels
|
|
self.assertEqual(
|
|
calls[0][0][0],
|
|
["kubectl", "get", "pod", "-n", "insights", "toolbox-pod-1", "-o", "jsonpath={.metadata.labels}"],
|
|
)
|
|
|
|
# Verify the annotation call
|
|
self.assertEqual(
|
|
calls[1][0][0],
|
|
[
|
|
"kubectl",
|
|
"annotate",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"karpenter.sh/do-not-disrupt=true",
|
|
"--overwrite=true",
|
|
],
|
|
)
|
|
|
|
# Verify the label call
|
|
self.assertEqual(
|
|
calls[2][0][0],
|
|
[
|
|
"kubectl",
|
|
"label",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"toolbox-claimed=michael.k_at_hanzo.ai",
|
|
"role-name=developers",
|
|
"assumed-role=true",
|
|
"terminate-after=1234567890",
|
|
],
|
|
)
|
|
|
|
# Verify the wait call
|
|
self.assertEqual(
|
|
calls[3][0][0],
|
|
["kubectl", "wait", "--for=condition=Ready", "--timeout=5m", "-n", "insights", "pod", "toolbox-pod-1"],
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod_custom_duration(self, mock_run):
|
|
"""Test claiming a pod with custom duration."""
|
|
# Mock kubectl get pod response
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps({"app.kubernetes.io/name": "insights-toolbox-django"})
|
|
|
|
# Mock kubectl label and annotate responses
|
|
mock_label_response = MagicMock()
|
|
mock_annotate_response = MagicMock()
|
|
|
|
# Set up the side effect to handle all kubectl calls
|
|
mock_run.side_effect = [
|
|
mock_get_response, # get pod labels
|
|
mock_annotate_response, # add annotation
|
|
mock_label_response, # add new labels
|
|
mock_label_response, # wait for pod
|
|
]
|
|
|
|
# Calculate timestamp 4 hours from now
|
|
future_timestamp = int(time.time()) + (4 * 60 * 60)
|
|
|
|
user_labels = {"toolbox-claimed": "michael.k_at_hanzo.ai", "role-name": "developers", "assumed-role": "true"}
|
|
claim_pod("toolbox-pod-1", user_labels, future_timestamp, namespace="insights")
|
|
|
|
# Verify kubectl commands were called correctly
|
|
self.assertEqual(mock_run.call_count, 4)
|
|
|
|
# Get all calls made to kubectl
|
|
calls = mock_run.call_args_list
|
|
|
|
# Verify the label call includes the future timestamp
|
|
self.assertEqual(
|
|
calls[2][0][0],
|
|
[
|
|
"kubectl",
|
|
"label",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"toolbox-claimed=michael.k_at_hanzo.ai",
|
|
"role-name=developers",
|
|
"assumed-role=true",
|
|
f"terminate-after={future_timestamp}",
|
|
],
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_update_claim(self, mock_run):
|
|
"""Test updating an existing pod claim."""
|
|
# Mock kubectl get pod response
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps(
|
|
{
|
|
"app.kubernetes.io/name": "insights-toolbox-django",
|
|
"toolbox-claimed": "michael.k_at_hanzo.ai",
|
|
"role-name": "developers",
|
|
"assumed-role": "true",
|
|
"terminate-after": "1234567890",
|
|
}
|
|
)
|
|
|
|
# Mock kubectl label and annotate responses
|
|
mock_label_response = MagicMock()
|
|
mock_annotate_response = MagicMock()
|
|
|
|
# Set up the side effect to handle all kubectl calls
|
|
mock_run.side_effect = [
|
|
mock_get_response, # get pod labels
|
|
mock_label_response, # batch-remove existing labels
|
|
mock_annotate_response, # add annotation
|
|
mock_label_response, # add new labels
|
|
mock_label_response, # wait for pod
|
|
]
|
|
|
|
# Calculate new timestamp 24 hours from now
|
|
future_timestamp = int(time.time()) + (24 * 60 * 60)
|
|
|
|
user_labels = {"toolbox-claimed": "michael.k_at_hanzo.ai", "role-name": "developers", "assumed-role": "true"}
|
|
claim_pod("toolbox-pod-1", user_labels, future_timestamp, namespace="insights")
|
|
|
|
# Verify kubectl commands were called correctly
|
|
self.assertEqual(mock_run.call_count, 5)
|
|
|
|
# Get all calls made to kubectl
|
|
calls = mock_run.call_args_list
|
|
|
|
# Verify the first call to get pod labels
|
|
self.assertEqual(
|
|
calls[0][0][0],
|
|
["kubectl", "get", "pod", "-n", "insights", "toolbox-pod-1", "-o", "jsonpath={.metadata.labels}"],
|
|
)
|
|
|
|
# Verify the batched label-removal call (single kubectl invocation)
|
|
self.assertEqual(
|
|
calls[1][0][0],
|
|
[
|
|
"kubectl",
|
|
"label",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"toolbox-claimed-",
|
|
"role-name-",
|
|
"assumed-role-",
|
|
"terminate-after-",
|
|
],
|
|
)
|
|
|
|
# Verify the annotation call
|
|
self.assertEqual(
|
|
calls[2][0][0],
|
|
[
|
|
"kubectl",
|
|
"annotate",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"karpenter.sh/do-not-disrupt=true",
|
|
"--overwrite=true",
|
|
],
|
|
)
|
|
|
|
# Verify the label call
|
|
self.assertEqual(
|
|
calls[3][0][0],
|
|
[
|
|
"kubectl",
|
|
"label",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"toolbox-claimed=michael.k_at_hanzo.ai",
|
|
"role-name=developers",
|
|
"assumed-role=true",
|
|
f"terminate-after={future_timestamp}",
|
|
],
|
|
)
|
|
|
|
# Verify the wait call
|
|
self.assertEqual(
|
|
calls[4][0][0],
|
|
["kubectl", "wait", "--for=condition=Ready", "--timeout=5m", "-n", "insights", "pod", "toolbox-pod-1"],
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod_with_resource_version_passes_flag_on_first_mutation(self, mock_run):
|
|
"""When resource_version is set, the FIRST mutating kubectl call carries --resource-version=<rv>.
|
|
|
|
Subsequent calls drop the flag because the resourceVersion advances after every
|
|
successful write; carrying the original rv on later calls would 409 against
|
|
ourselves.
|
|
"""
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps({"app.kubernetes.io/name": "insights-toolbox-django"})
|
|
# No labels_to_remove → annotate is the first mutation, so it carries the rv flag.
|
|
mock_run.side_effect = [
|
|
mock_get_response, # kubectl get pod labels
|
|
MagicMock(), # annotate (first mutation, with --resource-version)
|
|
MagicMock(), # label add (no rv flag)
|
|
MagicMock(), # wait
|
|
]
|
|
|
|
user_labels = {"toolbox-claimed": "u_at_hanzo.ai"}
|
|
claim_pod("toolbox-pod-1", user_labels, 1234567890, namespace="insights", resource_version="42")
|
|
|
|
annotate_args = mock_run.call_args_list[1][0][0]
|
|
label_args = mock_run.call_args_list[2][0][0]
|
|
self.assertIn("--resource-version=42", annotate_args)
|
|
self.assertNotIn("--resource-version=42", label_args)
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod_with_resource_version_attaches_to_label_strip_when_present(self, mock_run):
|
|
"""If existing labels need stripping, the strip step (not annotate) is the first mutation."""
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps(
|
|
{
|
|
"app.kubernetes.io/name": "insights-toolbox-django",
|
|
"pod-template-hash": "abc123",
|
|
}
|
|
)
|
|
mock_run.side_effect = [
|
|
mock_get_response, # kubectl get pod labels
|
|
MagicMock(), # label-strip (first mutation, with rv flag)
|
|
MagicMock(), # annotate (no rv flag)
|
|
MagicMock(), # label add (no rv flag)
|
|
MagicMock(), # wait
|
|
]
|
|
|
|
user_labels = {"toolbox-claimed": "u_at_hanzo.ai"}
|
|
claim_pod("toolbox-pod-1", user_labels, 1234567890, namespace="insights", resource_version="42")
|
|
|
|
strip_args = mock_run.call_args_list[1][0][0]
|
|
annotate_args = mock_run.call_args_list[2][0][0]
|
|
self.assertIn("--resource-version=42", strip_args)
|
|
self.assertNotIn("--resource-version=42", annotate_args)
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod_raises_claim_race_error_on_conflict(self, mock_run):
|
|
"""A 409 Conflict on the first mutation surfaces as ClaimRaceError so the caller can retry."""
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps({"app.kubernetes.io/name": "insights-toolbox-django"})
|
|
conflict = subprocess.CalledProcessError(
|
|
returncode=1,
|
|
cmd=["kubectl", "annotate", "pod"],
|
|
stderr="Error from server (Conflict): the object has been modified",
|
|
)
|
|
mock_run.side_effect = [
|
|
mock_get_response, # kubectl get pod labels
|
|
conflict, # annotate (first mutation) loses the race
|
|
]
|
|
|
|
user_labels = {"toolbox-claimed": "u_at_hanzo.ai"}
|
|
with self.assertRaises(ClaimRaceError):
|
|
claim_pod("toolbox-pod-1", user_labels, 1234567890, namespace="insights", resource_version="42")
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod_non_conflict_error_does_not_raise_race(self, mock_run):
|
|
"""A non-409 kubectl failure on the first mutation is not a race; the original error path applies."""
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps({"app.kubernetes.io/name": "insights-toolbox-django"})
|
|
non_conflict = subprocess.CalledProcessError(
|
|
returncode=1,
|
|
cmd=["kubectl", "annotate", "pod"],
|
|
stderr="Error from server (Forbidden): user cannot annotate pods",
|
|
)
|
|
mock_run.side_effect = [
|
|
mock_get_response, # kubectl get pod labels
|
|
non_conflict, # annotate fails with a non-409 error
|
|
]
|
|
|
|
user_labels = {"toolbox-claimed": "u_at_hanzo.ai"}
|
|
with self.assertRaises(SystemExit):
|
|
claim_pod("toolbox-pod-1", user_labels, 1234567890, namespace="insights", resource_version="42")
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod_with_context(self, mock_run):
|
|
"""claim_pod with context= passes --context to every kubectl invocation."""
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps({"app.kubernetes.io/name": "insights-toolbox-django"})
|
|
mock_run.side_effect = [
|
|
mock_get_response,
|
|
MagicMock(),
|
|
MagicMock(),
|
|
MagicMock(),
|
|
]
|
|
|
|
user_labels = {"toolbox-claimed": "u_at_hanzo.ai"}
|
|
claim_pod("toolbox-pod-1", user_labels, 1234567890, namespace="insights", context="insights-dev")
|
|
|
|
for call in mock_run.call_args_list:
|
|
cmd = call[0][0]
|
|
self.assertEqual(cmd[0], "kubectl")
|
|
self.assertEqual(cmd[1], "--context=insights-dev")
|
|
|
|
# New tests for Kubernetes context functions
|
|
@patch("subprocess.run")
|
|
def test_get_available_contexts(self, mock_run):
|
|
"""Test getting available kubernetes contexts."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = "context1\ncontext2\ncontext3"
|
|
mock_run.return_value = mock_response
|
|
|
|
contexts = get_available_contexts()
|
|
|
|
self.assertEqual(contexts, ["context1", "context2", "context3"])
|
|
mock_run.assert_called_once_with(
|
|
["kubectl", "config", "get-contexts", "-o", "name"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
timeout=15,
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_available_contexts_empty(self, mock_run):
|
|
"""Test getting available kubernetes contexts when none exist."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = ""
|
|
mock_run.return_value = mock_response
|
|
|
|
contexts = get_available_contexts()
|
|
|
|
self.assertEqual(contexts, [])
|
|
mock_run.assert_called_once()
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_available_contexts_error(self, mock_run):
|
|
"""Test error handling when getting available contexts fails."""
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, "kubectl", "error")
|
|
|
|
with self.assertRaises(SystemExit):
|
|
get_available_contexts()
|
|
|
|
mock_run.assert_called_once()
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_current_context(self, mock_run):
|
|
"""Test getting current kubernetes context."""
|
|
mock_response = MagicMock()
|
|
mock_response.stdout = "current-context"
|
|
mock_run.return_value = mock_response
|
|
|
|
context = get_current_context()
|
|
|
|
self.assertEqual(context, "current-context")
|
|
mock_run.assert_called_once_with(
|
|
["kubectl", "config", "current-context"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
timeout=15,
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_current_context_error(self, mock_run):
|
|
"""Test error handling when getting current context fails."""
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, "kubectl", "error")
|
|
|
|
context = get_current_context()
|
|
|
|
self.assertIsNone(context)
|
|
mock_run.assert_called_once()
|
|
|
|
@patch("subprocess.run")
|
|
def test_switch_context(self, mock_run):
|
|
"""Test switching kubernetes context."""
|
|
mock_run.return_value = MagicMock()
|
|
|
|
result = switch_context("new-context")
|
|
|
|
self.assertTrue(result)
|
|
mock_run.assert_called_once_with(["kubectl", "config", "use-context", "new-context"], check=True)
|
|
|
|
@patch("subprocess.run")
|
|
def test_switch_context_error(self, mock_run):
|
|
"""Test error handling when switching context fails."""
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, "kubectl", "error")
|
|
|
|
result = switch_context("invalid-context")
|
|
|
|
self.assertFalse(result)
|
|
mock_run.assert_called_once()
|
|
|
|
@patch("toolbox.kubernetes.get_available_contexts")
|
|
def test_validate_context_known(self, mock_get):
|
|
"""validate_context returns True for a context that's in the available list."""
|
|
mock_get.return_value = ["insights-dev", "insights-prod"]
|
|
self.assertTrue(validate_context("insights-dev"))
|
|
|
|
@patch("toolbox.kubernetes.get_available_contexts")
|
|
def test_validate_context_unknown(self, mock_get):
|
|
"""validate_context returns False for a context that isn't known."""
|
|
mock_get.return_value = ["insights-dev", "insights-prod"]
|
|
self.assertFalse(validate_context("insights-bogus"))
|
|
|
|
@patch("builtins.input")
|
|
def test_select_environment_only_offers_toolbox_environments(self, mock_input):
|
|
mock_input.side_effect = ["40", "0", "-1", "2"]
|
|
self.assertEqual(select_environment(), "prod-eu")
|
|
self.assertEqual(mock_input.call_count, 4)
|
|
|
|
@patch("toolbox.kubernetes.check_context_access")
|
|
@patch("toolbox.kubernetes.get_available_contexts")
|
|
@patch("toolbox.kubernetes.select_environment", return_value="dev")
|
|
def test_select_context_uses_least_privileged_working_context(self, mock_environment, mock_get, mock_access):
|
|
mock_get.return_value = ["unrelated", "dev-eks", "dev-write", "dev-admin"]
|
|
mock_access.side_effect = [(False, "no"), (True, "")]
|
|
|
|
self.assertEqual(select_context("insights"), "dev-write")
|
|
self.assertEqual(
|
|
mock_access.call_args_list,
|
|
[unittest.mock.call("dev-eks", "insights"), unittest.mock.call("dev-write", "insights")],
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_check_context_access_uses_kubernetes_identity(self, mock_run):
|
|
mock_run.return_value = MagicMock(returncode=0, stdout='{"status": {}}\n', stderr="")
|
|
self.assertEqual(check_context_access("dev-eks", "insights"), (True, ""))
|
|
mock_run.assert_called_once_with(
|
|
["kubectl", "--context=dev-eks", "auth", "whoami", "-o", "json"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=15,
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_context_profile_reads_local_kubeconfig(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
stdout=json.dumps(
|
|
{
|
|
"contexts": [{"name": "dev-eks", "context": {"user": "dev-eks-user"}}],
|
|
"users": [
|
|
{
|
|
"name": "dev-eks-user",
|
|
"user": {"exec": {"env": [{"name": "AWS_PROFILE", "value": "dev-eks"}]}},
|
|
}
|
|
],
|
|
}
|
|
)
|
|
)
|
|
self.assertEqual(get_context_profile("dev-eks"), "dev-eks")
|
|
|
|
@patch("toolbox.kubernetes.check_context_access", return_value=(True, ""))
|
|
@patch("toolbox.kubernetes.get_context_profile", return_value="dev-eks")
|
|
@patch("toolbox.kubernetes.time.monotonic", side_effect=[0, 1])
|
|
def test_wait_for_context_access_continues_when_grant_appears(self, mock_time, mock_profile, mock_access):
|
|
self.assertTrue(wait_for_context_access("dev-eks", "insights"))
|
|
mock_access.assert_called_once_with("dev-eks", "insights")
|
|
|
|
@patch("toolbox.kubernetes.login_to_sso", return_value=False)
|
|
@patch("toolbox.kubernetes.get_context_profile", return_value="dev-eks")
|
|
def test_wait_for_context_access_stops_when_required_sso_login_fails(self, mock_profile, mock_login):
|
|
self.assertFalse(
|
|
wait_for_context_access("dev-eks", "insights", initial_diagnostic="Token has expired and refresh failed")
|
|
)
|
|
mock_login.assert_called_once_with("dev-eks", timeout=unittest.mock.ANY)
|
|
|
|
def test_summarize_diagnostic_surfaces_aws_no_access(self):
|
|
diagnostic = "aws: [ERROR]: ForbiddenException when calling GetRoleCredentials: No access\nUnable to connect"
|
|
self.assertEqual(summarize_diagnostic(diagnostic), "AWS reports that this profile currently has no access")
|
|
|
|
def test_summarize_diagnostic_recognizes_whoami_unauthorized(self):
|
|
diagnostic = "error: You must be logged in to the server (Unauthorized)"
|
|
self.assertEqual(summarize_diagnostic(diagnostic), "Kubernetes rejected the cached AWS SSO credentials")
|
|
|
|
@patch("toolbox.kubernetes.login_to_sso", return_value=True)
|
|
@patch("toolbox.kubernetes.time.monotonic", return_value=10)
|
|
@patch("subprocess.run")
|
|
def test_reset_sso_logs_out_before_logging_back_in(self, mock_run, mock_time, mock_login):
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
self.assertTrue(reset_sso("prod-eu-eks", deadline=610))
|
|
mock_run.assert_called_once_with(["aws", "sso", "logout"], check=False, timeout=600)
|
|
mock_login.assert_called_once_with("prod-eu-eks", timeout=600)
|
|
|
|
@patch("toolbox.kubernetes.login_to_sso", return_value=True)
|
|
@patch("toolbox.kubernetes.check_context_access", return_value=(True, ""))
|
|
@patch("toolbox.kubernetes.get_context_profile", return_value="prod-eu-eks")
|
|
@patch("toolbox.kubernetes.time.monotonic", side_effect=[0, 1, 2])
|
|
def test_wait_refreshes_rejected_cached_credentials(self, mock_time, mock_profile, mock_access, mock_login):
|
|
diagnostic = "error: You must be logged in to the server (Unauthorized)"
|
|
self.assertTrue(wait_for_context_access("prod-eu-eks", "insights", initial_diagnostic=diagnostic))
|
|
mock_login.assert_called_once_with("prod-eu-eks", timeout=599)
|
|
|
|
@patch("subprocess.run")
|
|
def test_claim_pod_wait_timeout(self, mock_run):
|
|
"""Test claim_pod exits if pod does not become ready within 5 minutes."""
|
|
mock_get_response = MagicMock()
|
|
mock_get_response.stdout = json.dumps({"app.kubernetes.io/name": "insights-toolbox-django"})
|
|
mock_annotate_response = MagicMock()
|
|
mock_label_response = MagicMock()
|
|
call_count = {"n": 0}
|
|
|
|
def side_effect(*args, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return mock_get_response
|
|
elif call_count["n"] == 2:
|
|
return mock_annotate_response
|
|
elif call_count["n"] == 3:
|
|
return mock_label_response
|
|
elif call_count["n"] == 4:
|
|
raise subprocess.CalledProcessError(1, args[0], "Timed out")
|
|
else:
|
|
return mock_label_response
|
|
|
|
mock_run.side_effect = side_effect
|
|
user_labels = {"toolbox-claimed": "michael.k_at_hanzo.ai", "role-name": "developers", "assumed-role": "true"}
|
|
with self.assertRaises(SystemExit):
|
|
claim_pod("toolbox-pod-1", user_labels, 1234567890, namespace="insights")
|
|
|
|
def test_pools_definition(self):
|
|
"""POOLS contains both pools with the expected app/claim labels and default namespaces."""
|
|
self.assertEqual(
|
|
toolbox_script.POOLS["toolbox-django"],
|
|
{
|
|
"default_namespace": "insights-toolbox-django",
|
|
"app_label": "insights-toolbox-django",
|
|
"claimed_label_key": "toolbox-claimed",
|
|
"extra_selectors_by_namespace": {
|
|
"insights-toolbox-django": "app.kubernetes.io/component=app",
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
toolbox_script.POOLS["flags-cache-jumphost"],
|
|
{
|
|
"default_namespace": "insights",
|
|
"app_label": "flags-cache-jumphost",
|
|
"claimed_label_key": "flags-jumphost-claimed",
|
|
},
|
|
)
|
|
|
|
def test_exit_for_signal_raises_systemexit_for_sigterm(self):
|
|
"""SIGTERM handler routes through sys.exit so atexit-registered cleanup fires."""
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
toolbox_script._exit_for_signal(signal.SIGTERM, None)
|
|
self.assertEqual(ctx.exception.code, 128 + int(signal.SIGTERM))
|
|
|
|
def test_exit_for_signal_raises_systemexit_for_sighup(self):
|
|
"""SIGHUP handler routes through sys.exit so atexit-registered cleanup fires.
|
|
|
|
SIGHUP fires on terminal close (closing iTerm, SSH disconnect, killing a tmux
|
|
pane); without this routing the claimed pod would leak.
|
|
"""
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
toolbox_script._exit_for_signal(signal.SIGHUP, None)
|
|
self.assertEqual(ctx.exception.code, 128 + int(signal.SIGHUP))
|
|
|
|
@patch("builtins.input")
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_auto_yes_skips_prompt(self, mock_run, mock_input):
|
|
"""delete_pod(auto_yes=True) does not call input() and uses --ignore-not-found."""
|
|
delete_pod("toolbox-pod-1", namespace="insights", auto_yes=True)
|
|
|
|
mock_input.assert_not_called()
|
|
mock_run.assert_called_once_with(
|
|
[
|
|
"kubectl",
|
|
"delete",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"--ignore-not-found",
|
|
"--wait=false",
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
@patch("builtins.input")
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_with_context_passes_flag(self, mock_run, mock_input):
|
|
"""delete_pod with context= scopes the kubectl delete to that context."""
|
|
delete_pod("toolbox-pod-1", namespace="insights", context="insights-dev", auto_yes=True)
|
|
|
|
mock_input.assert_not_called()
|
|
mock_run.assert_called_once_with(
|
|
[
|
|
"kubectl",
|
|
"--context=insights-dev",
|
|
"delete",
|
|
"pod",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"--ignore-not-found",
|
|
"--wait=false",
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
@patch("builtins.input", return_value="y")
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_prompt_yes_invokes_kubectl(self, mock_run, mock_input):
|
|
"""delete_pod(auto_yes=False) with a 'y' response invokes kubectl delete."""
|
|
delete_pod("toolbox-pod-1", namespace="insights")
|
|
|
|
mock_input.assert_called_once()
|
|
mock_run.assert_called_once()
|
|
|
|
@patch("builtins.input", return_value="n")
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_prompt_no_skips_kubectl(self, mock_run, mock_input):
|
|
"""delete_pod(auto_yes=False) with a non-'y' response does not call kubectl."""
|
|
delete_pod("toolbox-pod-1", namespace="insights")
|
|
|
|
mock_input.assert_called_once()
|
|
mock_run.assert_not_called()
|
|
|
|
@patch("builtins.print", side_effect=BrokenPipeError("hung-up PTY"))
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_runs_kubectl_when_stdout_is_broken(self, mock_run, mock_print):
|
|
"""delete_pod must still run kubectl delete when stdout is hung up.
|
|
|
|
Reproduces the SIGHUP-cleanup leak: when the user closes their terminal
|
|
tab, atexit fires delete_pod *after* the controlling PTY is gone.
|
|
Pre-fix, the very first ``print('🗑️ Deleting pod...')`` raised
|
|
BrokenPipeError before kubectl was reached, leaking a claimed pod.
|
|
"""
|
|
delete_pod("toolbox-pod-1", namespace="insights", auto_yes=True)
|
|
|
|
mock_run.assert_called_once()
|
|
cmd = mock_run.call_args[0][0]
|
|
self.assertEqual(cmd[0], "kubectl")
|
|
self.assertIn("delete", cmd)
|
|
self.assertIn("toolbox-pod-1", cmd)
|
|
|
|
@patch("builtins.input", side_effect=BrokenPipeError("hung-up PTY"))
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_prompt_path_with_broken_input_skips_delete(self, mock_run, mock_input):
|
|
"""When input() can't read (no TTY / hung-up), default to NOT deleting.
|
|
|
|
Matches the safety semantics of an explicit user 'n' so we never delete
|
|
a non-auto-delete pod without explicit consent.
|
|
"""
|
|
delete_pod("toolbox-pod-1", namespace="insights", auto_yes=False)
|
|
|
|
mock_input.assert_called_once()
|
|
mock_run.assert_not_called()
|
|
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_skips_when_label_does_not_match(self, mock_run):
|
|
"""When expected_label_* are passed (atexit path) and the label doesn't match,
|
|
the kubectl delete is skipped to protect against deleting an unclaimed pool pod
|
|
when claim_pod() failed before any label was written.
|
|
"""
|
|
# kubectl get returns an empty label value (pod was never claimed).
|
|
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
|
|
delete_pod(
|
|
"toolbox-pod-1",
|
|
namespace="insights",
|
|
auto_yes=True,
|
|
expected_label_key="toolbox-claimed",
|
|
expected_label_value="user_at_hanzo.ai",
|
|
)
|
|
|
|
# Exactly one kubectl call: the verification get. No delete was issued.
|
|
self.assertEqual(mock_run.call_count, 1)
|
|
cmd = mock_run.call_args[0][0]
|
|
self.assertIn("get", cmd)
|
|
self.assertNotIn("delete", cmd)
|
|
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_proceeds_when_label_matches(self, mock_run):
|
|
"""When the label check confirms our claim, delete_pod proceeds with the kubectl delete."""
|
|
# First call (get) returns the matching label value; second call (delete) succeeds.
|
|
mock_run.side_effect = [
|
|
subprocess.CompletedProcess(args=[], returncode=0, stdout="user_at_hanzo.ai", stderr=""),
|
|
subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
|
|
]
|
|
|
|
delete_pod(
|
|
"toolbox-pod-1",
|
|
namespace="insights",
|
|
auto_yes=True,
|
|
expected_label_key="toolbox-claimed",
|
|
expected_label_value="user_at_hanzo.ai",
|
|
)
|
|
|
|
self.assertEqual(mock_run.call_count, 2)
|
|
delete_cmd = mock_run.call_args_list[1][0][0]
|
|
self.assertIn("delete", delete_cmd)
|
|
self.assertIn("toolbox-pod-1", delete_cmd)
|
|
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_skips_when_label_check_errors(self, mock_run):
|
|
"""If the label-verification kubectl get errors out, default to NOT deleting.
|
|
|
|
Leaving an orphan to be reaped is preferable to silently shrinking the pool.
|
|
"""
|
|
mock_run.side_effect = subprocess.CalledProcessError(
|
|
returncode=1,
|
|
cmd=["kubectl", "get"],
|
|
stderr="forbidden",
|
|
)
|
|
|
|
delete_pod(
|
|
"toolbox-pod-1",
|
|
namespace="insights",
|
|
auto_yes=True,
|
|
expected_label_key="toolbox-claimed",
|
|
expected_label_value="user_at_hanzo.ai",
|
|
)
|
|
|
|
# Only the failing get call; no delete attempted.
|
|
self.assertEqual(mock_run.call_count, 1)
|
|
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_without_expected_label_skips_verification(self, mock_run):
|
|
"""Without expected_label_*, no extra kubectl get is issued (interactive 'y' path)."""
|
|
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
|
|
delete_pod("toolbox-pod-1", namespace="insights", auto_yes=True)
|
|
|
|
self.assertEqual(mock_run.call_count, 1)
|
|
cmd = mock_run.call_args[0][0]
|
|
self.assertIn("delete", cmd)
|
|
|
|
@patch("builtins.print", side_effect=BrokenPipeError("hung-up PTY"))
|
|
@patch("subprocess.run")
|
|
def test_delete_pod_swallows_kubectl_failure_when_stdout_broken(self, mock_run, mock_print):
|
|
"""If kubectl delete fails AND stdout is broken, delete_pod must not raise.
|
|
|
|
Otherwise a transient kubectl failure during atexit would propagate and
|
|
skip any subsequent atexit handlers.
|
|
"""
|
|
mock_run.side_effect = subprocess.CalledProcessError(
|
|
returncode=1,
|
|
cmd=["kubectl", "delete"],
|
|
stderr="some error",
|
|
)
|
|
|
|
# Should not raise:
|
|
delete_pod("toolbox-pod-1", namespace="insights", auto_yes=True)
|
|
|
|
mock_run.assert_called_once()
|
|
|
|
@patch("subprocess.run")
|
|
def test_connect_to_pod_returns_zero_on_success(self, mock_run):
|
|
"""connect_to_pod returns 0 when kubectl exec exits cleanly."""
|
|
from toolbox.pod import connect_to_pod
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_run.return_value = mock_result
|
|
|
|
rc = connect_to_pod("toolbox-pod-1", namespace="insights")
|
|
self.assertEqual(rc, 0)
|
|
|
|
@patch("subprocess.run")
|
|
def test_connect_to_pod_returns_nonzero_on_failure(self, mock_run):
|
|
"""connect_to_pod surfaces a non-zero exit code from kubectl exec.
|
|
|
|
The original code used subprocess.run without checking returncode, so RBAC
|
|
denials, missing pods, and network failures silently looked successful and
|
|
triggered downstream cleanup of a session that never opened.
|
|
"""
|
|
from toolbox.pod import connect_to_pod
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 1
|
|
mock_run.return_value = mock_result
|
|
|
|
rc = connect_to_pod("toolbox-pod-1", namespace="insights")
|
|
self.assertEqual(rc, 1)
|
|
|
|
@patch("subprocess.run")
|
|
def test_connect_to_pod_with_context(self, mock_run):
|
|
"""connect_to_pod with context= passes --context to kubectl exec."""
|
|
from toolbox.pod import connect_to_pod
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_run.return_value = mock_result
|
|
|
|
connect_to_pod("toolbox-pod-1", namespace="insights", context="insights-dev")
|
|
mock_run.assert_called_once_with(
|
|
[
|
|
"kubectl",
|
|
"--context=insights-dev",
|
|
"exec",
|
|
"-it",
|
|
"-n",
|
|
"insights",
|
|
"toolbox-pod-1",
|
|
"--",
|
|
"bash",
|
|
]
|
|
)
|
|
|
|
def _patch_main_collaborators(self, *, get_pod_returns=("toolbox-pod-1", False, "12345")):
|
|
"""Common patch stack for main() integration tests.
|
|
|
|
Patches everything main() depends on so the test exercises only the dispatch
|
|
wiring (POOLS lookup, KUBE_CONTEXT branch, kwargs threading, race-retry, etc.).
|
|
"""
|
|
connect_mock = MagicMock(return_value=0)
|
|
return {
|
|
"get_current_user": patch.object(
|
|
toolbox_script,
|
|
"get_current_user",
|
|
return_value={"toolbox-claimed": "user_at_hanzo.ai"},
|
|
),
|
|
"get_toolbox_pod": patch.object(toolbox_script, "get_toolbox_pod", return_value=get_pod_returns),
|
|
"claim_pod": patch.object(toolbox_script, "claim_pod"),
|
|
"connect_to_pod": patch.object(toolbox_script, "connect_to_pod", new=connect_mock),
|
|
"delete_pod": patch.object(toolbox_script, "delete_pod"),
|
|
"select_context": patch.object(toolbox_script, "select_context", return_value="insights-dev"),
|
|
"validate_context": patch.object(toolbox_script, "validate_context", return_value=True),
|
|
}
|
|
|
|
def _clean_env(self):
|
|
os.environ.pop("KUBE_CONTEXT", None)
|
|
os.environ.pop("KUBE_NAMESPACE", None)
|
|
os.environ.pop("FLOX_ENV", None)
|
|
|
|
def test_main_jumphost_pool_dispatches_correct_kwargs(self):
|
|
"""--pool flags-cache-jumphost threads the jumphost app/claim labels into helpers."""
|
|
patches = self._patch_main_collaborators()
|
|
# Override get_current_user for the jumphost pool's claim key.
|
|
patches["get_current_user"] = patch.object(
|
|
toolbox_script,
|
|
"get_current_user",
|
|
return_value={"flags-jumphost-claimed": "user_at_hanzo.ai"},
|
|
)
|
|
|
|
with (
|
|
patches["get_current_user"] as m_user,
|
|
patches["get_toolbox_pod"] as m_get_pod,
|
|
patches["claim_pod"] as m_claim,
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--pool", "flags-cache-jumphost"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
toolbox_script.main()
|
|
self.assertEqual(ctx.exception.code, 0)
|
|
|
|
m_user.assert_called_once_with(claimed_label_key="flags-jumphost-claimed", context="insights-dev")
|
|
m_get_pod.assert_called_once_with(
|
|
"user_at_hanzo.ai",
|
|
check_claimed=True,
|
|
app_label="flags-cache-jumphost",
|
|
claimed_label_key="flags-jumphost-claimed",
|
|
namespace="insights",
|
|
context="insights-dev",
|
|
extra_selector=None,
|
|
)
|
|
# claim_pod gets namespace, context, and resource_version from get_toolbox_pod's return.
|
|
self.assertEqual(
|
|
m_claim.call_args.kwargs,
|
|
{"namespace": "insights", "context": "insights-dev", "resource_version": "12345"},
|
|
)
|
|
|
|
def test_main_default_pool_dispatches_toolbox_django_kwargs(self):
|
|
"""--pool toolbox-django (the default) threads the right kwargs per active namespace.
|
|
|
|
With no KUBE_NAMESPACE the pool resolves to its `default_namespace`
|
|
(`insights-toolbox-django`) and the `component=app` selector is added so the
|
|
wrapper can't claim a pgbouncer pod (the chart's pgbouncers share
|
|
`app.kubernetes.io/name=insights-toolbox-django` with the toolbox itself).
|
|
`KUBE_NAMESPACE=insights` remains an escape hatch back to the legacy stack,
|
|
which doesn't need (or have) the `component=app` discriminator.
|
|
"""
|
|
cases = [
|
|
# (env_namespace_override, resolved_namespace, expected_extra_selector)
|
|
(None, "insights-toolbox-django", "app.kubernetes.io/component=app"),
|
|
("insights", "insights", None),
|
|
]
|
|
for env_namespace, resolved_namespace, expected_extra in cases:
|
|
with self.subTest(env_namespace=env_namespace):
|
|
patches = self._patch_main_collaborators()
|
|
env_override = {"KUBE_NAMESPACE": env_namespace} if env_namespace else {}
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"] as m_get_pod,
|
|
patches["claim_pod"],
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py"]),
|
|
patch.dict(os.environ, env_override, clear=False),
|
|
):
|
|
os.environ.pop("KUBE_CONTEXT", None)
|
|
os.environ.pop("FLOX_ENV", None)
|
|
if env_namespace is None:
|
|
os.environ.pop("KUBE_NAMESPACE", None)
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
m_get_pod.assert_called_once_with(
|
|
"user_at_hanzo.ai",
|
|
check_claimed=True,
|
|
app_label="insights-toolbox-django",
|
|
claimed_label_key="toolbox-claimed",
|
|
namespace=resolved_namespace,
|
|
context="insights-dev",
|
|
extra_selector=expected_extra,
|
|
)
|
|
|
|
def test_main_kube_context_env_validates_without_switching(self):
|
|
"""When KUBE_CONTEXT is set, main() validates it and threads it as --context, never calling switch_context."""
|
|
patches = self._patch_main_collaborators()
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"] as m_get_pod,
|
|
patches["claim_pod"],
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"] as m_select,
|
|
patches["validate_context"] as m_validate,
|
|
patch.object(toolbox_script, "ensure_context_access", return_value=True) as m_access,
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py"]),
|
|
patch.dict(os.environ, {"KUBE_CONTEXT": "insights-dev"}, clear=False),
|
|
):
|
|
os.environ.pop("KUBE_NAMESPACE", None)
|
|
os.environ.pop("FLOX_ENV", None)
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
m_validate.assert_called_once_with("insights-dev")
|
|
m_access.assert_called_once_with("insights-dev", "insights-toolbox-django")
|
|
m_select.assert_not_called()
|
|
# And the resolved context is threaded into get_toolbox_pod.
|
|
self.assertEqual(m_get_pod.call_args.kwargs["context"], "insights-dev")
|
|
|
|
def test_main_kube_context_unset_falls_back_to_select_context(self):
|
|
"""When KUBE_CONTEXT is unset, main() prompts via select_context and uses its return as --context."""
|
|
patches = self._patch_main_collaborators()
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"] as m_get_pod,
|
|
patches["claim_pod"],
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"] as m_select,
|
|
patches["validate_context"] as m_validate,
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
m_select.assert_called_once_with("insights-toolbox-django")
|
|
m_validate.assert_not_called()
|
|
self.assertEqual(m_get_pod.call_args.kwargs["context"], "insights-dev")
|
|
|
|
def test_main_kube_context_validate_failure_exits(self):
|
|
"""When validate_context returns False, main() exits with status 1."""
|
|
patches = self._patch_main_collaborators()
|
|
patches["validate_context"] = patch.object(toolbox_script, "validate_context", return_value=False)
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"],
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py"]),
|
|
patch.dict(os.environ, {"KUBE_CONTEXT": "bogus"}, clear=False),
|
|
):
|
|
os.environ.pop("FLOX_ENV", None)
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
toolbox_script.main()
|
|
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
|
|
def test_main_auto_delete_arms_atexit_before_claim_pod(self):
|
|
"""atexit.register must run BEFORE claim_pod() so a Ctrl-C during the up-to-5min Ready wait still cleans up."""
|
|
patches = self._patch_main_collaborators()
|
|
|
|
parent = MagicMock()
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"] as m_claim,
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.atexit, "register") as m_atexit,
|
|
patch.object(toolbox_script.signal, "signal"),
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
parent.attach_mock(m_atexit, "atexit_register")
|
|
parent.attach_mock(m_claim, "claim_pod")
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
call_names = [name for name, _, _ in parent.mock_calls]
|
|
first_atexit = call_names.index("atexit_register")
|
|
first_claim = call_names.index("claim_pod")
|
|
self.assertLess(first_atexit, first_claim)
|
|
|
|
def test_main_auto_delete_registers_signal_handlers_and_atexit(self):
|
|
"""--auto-delete registers atexit cleanup and SIGTERM/SIGHUP handlers when claiming a fresh pod."""
|
|
patches = self._patch_main_collaborators()
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"],
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"] as m_delete,
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.atexit, "register") as m_atexit,
|
|
patch.object(toolbox_script.signal, "signal") as m_signal,
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
# atexit cleanup is registered with auto_yes=True, resolved context, and the
|
|
# claim label so delete_pod can verify before deleting an unclaimed pool pod.
|
|
m_atexit.assert_called_once_with(
|
|
m_delete,
|
|
"toolbox-pod-1",
|
|
namespace="insights-toolbox-django",
|
|
context="insights-dev",
|
|
auto_yes=True,
|
|
expected_label_key="toolbox-claimed",
|
|
expected_label_value="user_at_hanzo.ai",
|
|
)
|
|
# Both SIGTERM and SIGHUP route to _exit_for_signal.
|
|
signal_calls = {c.args[0]: c.args[1] for c in m_signal.call_args_list}
|
|
self.assertIs(signal_calls[signal.SIGTERM], toolbox_script._exit_for_signal)
|
|
self.assertIs(signal_calls[signal.SIGHUP], toolbox_script._exit_for_signal)
|
|
|
|
def test_main_auto_delete_skipped_on_pure_reattach(self):
|
|
"""When reattaching to an already-claimed pod (no --update-claim), --auto-delete must NOT register cleanup.
|
|
|
|
The pod may still be in active use by another shell of yours; deleting it
|
|
when this shell exits would kill that other session.
|
|
"""
|
|
patches = self._patch_main_collaborators(get_pod_returns=("toolbox-pod-1", True, "55555"))
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"] as m_claim,
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.atexit, "register") as m_atexit,
|
|
patch.object(toolbox_script.signal, "signal") as m_signal,
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
m_atexit.assert_not_called()
|
|
m_signal.assert_not_called()
|
|
m_claim.assert_not_called() # Pure reattach: no claim mutation either.
|
|
|
|
def test_main_auto_delete_armed_when_extending_own_claim(self):
|
|
"""--auto-delete WITH --update-claim DOES register cleanup because we are actively (re-)claiming."""
|
|
patches = self._patch_main_collaborators(get_pod_returns=("toolbox-pod-1", True, "55555"))
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"] as m_claim,
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"] as m_delete,
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.atexit, "register") as m_atexit,
|
|
patch.object(toolbox_script.signal, "signal"),
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete", "--update-claim"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
m_atexit.assert_called_once_with(
|
|
m_delete,
|
|
"toolbox-pod-1",
|
|
namespace="insights-toolbox-django",
|
|
context="insights-dev",
|
|
auto_yes=True,
|
|
expected_label_key="toolbox-claimed",
|
|
expected_label_value="user_at_hanzo.ai",
|
|
)
|
|
# update-claim path passes resource_version=None because we already own the pod.
|
|
self.assertEqual(m_claim.call_args.kwargs["resource_version"], None)
|
|
|
|
def test_main_propagates_connect_to_pod_exit_code(self):
|
|
"""When kubectl exec returns non-zero, main() exits with the same code."""
|
|
patches = self._patch_main_collaborators()
|
|
patches["connect_to_pod"] = patch.object(toolbox_script, "connect_to_pod", return_value=42)
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"],
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"],
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
toolbox_script.main()
|
|
|
|
self.assertEqual(ctx.exception.code, 42)
|
|
|
|
def test_main_retries_on_claim_race_and_picks_new_pod(self):
|
|
"""A ClaimRaceError on the first attempt triggers a re-fetch and retry against the next available pod."""
|
|
patches = self._patch_main_collaborators()
|
|
# Simulate: first get returns pod-A; first claim races; second get returns pod-B; second claim succeeds.
|
|
patches["get_toolbox_pod"] = patch.object(
|
|
toolbox_script,
|
|
"get_toolbox_pod",
|
|
side_effect=[
|
|
("toolbox-pod-A", False, "1"),
|
|
("toolbox-pod-B", False, "2"),
|
|
],
|
|
)
|
|
patches["claim_pod"] = patch.object(
|
|
toolbox_script,
|
|
"claim_pod",
|
|
side_effect=[ClaimRaceError("racing"), None],
|
|
)
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"] as m_get_pod,
|
|
patches["claim_pod"] as m_claim,
|
|
patches["connect_to_pod"] as m_connect,
|
|
patches["delete_pod"] as m_delete,
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.atexit, "register") as m_atexit,
|
|
patch.object(toolbox_script.atexit, "unregister") as m_unregister,
|
|
patch.object(toolbox_script.signal, "signal"),
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
self.assertEqual(m_get_pod.call_count, 2)
|
|
self.assertEqual(m_claim.call_count, 2)
|
|
# We connected to the pod we won, not the one we lost.
|
|
m_connect.assert_called_once_with("toolbox-pod-B", namespace="insights-toolbox-django", context="insights-dev")
|
|
# atexit was registered twice (once per candidate pod) and unregistered once
|
|
# to clear the stale registration after the race.
|
|
self.assertEqual(m_atexit.call_count, 2)
|
|
self.assertEqual(m_unregister.call_count, 1)
|
|
m_unregister.assert_called_with(m_delete)
|
|
|
|
def test_main_gives_up_after_max_claim_retries(self):
|
|
"""If every attempt races, main() exits 1 rather than looping forever.
|
|
|
|
Also asserts that the stale atexit registration is cleared before the
|
|
terminal exit — otherwise atexit would fire delete_pod against an
|
|
unclaimed candidate pod and silently shrink the pool on every
|
|
retry-exhaustion.
|
|
"""
|
|
max_retries = toolbox_script.MAX_CLAIM_RETRIES
|
|
patches = self._patch_main_collaborators()
|
|
patches["get_toolbox_pod"] = patch.object(
|
|
toolbox_script,
|
|
"get_toolbox_pod",
|
|
side_effect=[(f"pod-{i}", False, str(i)) for i in range(max_retries + 2)],
|
|
)
|
|
patches["claim_pod"] = patch.object(
|
|
toolbox_script, "claim_pod", side_effect=[ClaimRaceError("racing")] * (max_retries + 1)
|
|
)
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"] as m_claim,
|
|
patches["connect_to_pod"],
|
|
patches["delete_pod"] as m_delete,
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.atexit, "register") as m_atexit,
|
|
patch.object(toolbox_script.atexit, "unregister") as m_unregister,
|
|
patch.object(toolbox_script.signal, "signal"),
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
toolbox_script.main()
|
|
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
self.assertEqual(m_claim.call_count, max_retries)
|
|
# One register for the initial pre-claim arming, one per candidate after
|
|
# each lost race (max_retries - 1 in-loop + 1 final dead candidate when
|
|
# the final claim races) — and one matching unregister per register,
|
|
# including the final one before sys.exit(1).
|
|
self.assertEqual(m_atexit.call_count, m_unregister.call_count)
|
|
self.assertGreaterEqual(m_unregister.call_count, max_retries)
|
|
m_unregister.assert_called_with(m_delete)
|
|
|
|
def test_main_race_resolves_to_already_claimed_skips_auto_delete(self):
|
|
"""If, after losing a race, the next get_toolbox_pod finds a pod already claimed by us,
|
|
we attach but DO NOT register cleanup against it (it could be another shell's claim)."""
|
|
patches = self._patch_main_collaborators()
|
|
patches["get_toolbox_pod"] = patch.object(
|
|
toolbox_script,
|
|
"get_toolbox_pod",
|
|
side_effect=[
|
|
("toolbox-pod-A", False, "1"),
|
|
("toolbox-pod-mine", True, "9"),
|
|
],
|
|
)
|
|
patches["claim_pod"] = patch.object(
|
|
toolbox_script,
|
|
"claim_pod",
|
|
side_effect=[ClaimRaceError("racing")],
|
|
)
|
|
|
|
with (
|
|
patches["get_current_user"],
|
|
patches["get_toolbox_pod"],
|
|
patches["claim_pod"] as m_claim,
|
|
patches["connect_to_pod"] as m_connect,
|
|
patches["delete_pod"] as m_delete,
|
|
patches["select_context"],
|
|
patches["validate_context"],
|
|
patch.object(toolbox_script.atexit, "register") as m_atexit,
|
|
patch.object(toolbox_script.atexit, "unregister") as m_unregister,
|
|
patch.object(toolbox_script.signal, "signal"),
|
|
patch.object(toolbox_script.sys, "argv", ["toolbox.py", "--auto-delete"]),
|
|
patch.dict(os.environ, {}, clear=False),
|
|
):
|
|
self._clean_env()
|
|
with self.assertRaises(SystemExit):
|
|
toolbox_script.main()
|
|
|
|
m_connect.assert_called_once_with("toolbox-pod-mine", namespace="insights-toolbox-django", context="insights-dev")
|
|
# claim_pod was called once (the racing one) and not retried since we found our own pod.
|
|
self.assertEqual(m_claim.call_count, 1)
|
|
# First atexit register armed cleanup for pod-A; on the race we unregistered;
|
|
# we did NOT re-register for pod-mine.
|
|
self.assertEqual(m_atexit.call_count, 1)
|
|
self.assertEqual(m_unregister.call_count, 1)
|
|
m_unregister.assert_called_with(m_delete)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|