jumpstarter-kubernetes 0.6.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of jumpstarter-kubernetes might be problematic. Click here for more details.

Files changed (21) hide show
  1. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/PKG-INFO +2 -2
  2. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/__init__.py +20 -1
  3. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/clients.py +27 -0
  4. jumpstarter_kubernetes-0.7.1/jumpstarter_kubernetes/cluster.py +201 -0
  5. jumpstarter_kubernetes-0.7.1/jumpstarter_kubernetes/cluster_test.py +395 -0
  6. jumpstarter_kubernetes-0.7.1/jumpstarter_kubernetes/datetime.py +32 -0
  7. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/exporters.py +54 -0
  8. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/install.py +26 -6
  9. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/leases.py +63 -0
  10. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/pyproject.toml +1 -1
  11. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/.gitignore +0 -0
  12. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/README.md +0 -0
  13. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/clients_test.py +0 -0
  14. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/exporters_test.py +0 -0
  15. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/json.py +0 -0
  16. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/list.py +0 -0
  17. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/py.typed +0 -0
  18. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/serialize.py +0 -0
  19. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/test_leases.py +0 -0
  20. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/util/__init__.py +0 -0
  21. {jumpstarter_kubernetes-0.6.0 → jumpstarter_kubernetes-0.7.1}/jumpstarter_kubernetes/util/async_custom_object_api.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jumpstarter-kubernetes
3
- Version: 0.6.0
3
+ Version: 0.7.1
4
4
  Project-URL: Homepage, https://jumpstarter.dev
5
- Project-URL: source_archive, https://github.com/jumpstarter-dev/repo/archive/c2927a2abac82d224c7bd28f9ed83c57b5222e65.zip
5
+ Project-URL: source_archive, https://github.com/jumpstarter-dev/repo/archive/7a5a95ea4fab743def1fe84f93165fbd49fc2de7.zip
6
6
  Author-email: Kirk Brauer <kbrauer@hatci.com>
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: >=3.11
@@ -1,4 +1,12 @@
1
1
  from .clients import ClientsV1Alpha1Api, V1Alpha1Client, V1Alpha1ClientList, V1Alpha1ClientStatus
2
+ from .cluster import (
3
+ create_kind_cluster,
4
+ create_minikube_cluster,
5
+ delete_kind_cluster,
6
+ delete_minikube_cluster,
7
+ kind_installed,
8
+ minikube_installed,
9
+ )
2
10
  from .exporters import (
3
11
  ExportersV1Alpha1Api,
4
12
  V1Alpha1Exporter,
@@ -6,7 +14,11 @@ from .exporters import (
6
14
  V1Alpha1ExporterList,
7
15
  V1Alpha1ExporterStatus,
8
16
  )
9
- from .install import helm_installed, install_helm_chart
17
+ from .install import (
18
+ helm_installed,
19
+ install_helm_chart,
20
+ uninstall_helm_chart,
21
+ )
10
22
  from .leases import (
11
23
  LeasesV1Alpha1Api,
12
24
  V1Alpha1Lease,
@@ -36,4 +48,11 @@ __all__ = [
36
48
  "V1Alpha1List",
37
49
  "helm_installed",
38
50
  "install_helm_chart",
51
+ "uninstall_helm_chart",
52
+ "minikube_installed",
53
+ "kind_installed",
54
+ "create_minikube_cluster",
55
+ "create_kind_cluster",
56
+ "delete_minikube_cluster",
57
+ "delete_kind_cluster",
39
58
  ]
@@ -53,6 +53,21 @@ class V1Alpha1Client(JsonBaseModel):
53
53
  else None,
54
54
  )
55
55
 
56
+ @classmethod
57
+ def rich_add_columns(cls, table):
58
+ table.add_column("NAME")
59
+ table.add_column("ENDPOINT")
60
+ # table.add_column("AGE")
61
+
62
+ def rich_add_rows(self, table):
63
+ table.add_row(
64
+ self.metadata.name,
65
+ self.status.endpoint if self.status is not None else "",
66
+ )
67
+
68
+ def rich_add_names(self, names):
69
+ names.append(f"client.jumpstarter.dev/{self.metadata.name}")
70
+
56
71
 
57
72
  class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]):
58
73
  kind: Literal["ClientList"] = Field(default="ClientList")
@@ -61,6 +76,18 @@ class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]):
61
76
  def from_dict(dict: dict):
62
77
  return V1Alpha1ClientList(items=[V1Alpha1Client.from_dict(c) for c in dict.get("items", [])])
63
78
 
79
+ @classmethod
80
+ def rich_add_columns(cls, table):
81
+ V1Alpha1Client.rich_add_columns(table)
82
+
83
+ def rich_add_rows(self, table):
84
+ for client in self.items:
85
+ client.rich_add_rows(table)
86
+
87
+ def rich_add_names(self, names):
88
+ for client in self.items:
89
+ client.rich_add_names(names)
90
+
64
91
 
65
92
  class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi):
66
93
  """Interact with the clients custom resource API"""
@@ -0,0 +1,201 @@
1
+ import asyncio
2
+ import shutil
3
+ from typing import Optional, Tuple
4
+
5
+
6
+ def minikube_installed(minikube: str) -> bool:
7
+ """Check if Minikube is installed and available in the PATH."""
8
+ return shutil.which(minikube) is not None
9
+
10
+
11
+ def kind_installed(kind: str) -> bool:
12
+ """Check if Kind is installed and available in the PATH."""
13
+ return shutil.which(kind) is not None
14
+
15
+
16
+ async def run_command(cmd: list[str]) -> Tuple[int, str, str]:
17
+ """Run a command and return exit code, stdout, stderr"""
18
+ try:
19
+ process = await asyncio.create_subprocess_exec(
20
+ *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
21
+ )
22
+ stdout, stderr = await process.communicate()
23
+ return process.returncode, stdout.decode().strip(), stderr.decode().strip()
24
+ except FileNotFoundError as e:
25
+ raise RuntimeError(f"Command not found: {cmd[0]}") from e
26
+
27
+
28
+ async def run_command_with_output(cmd: list[str]) -> int:
29
+ """Run a command with real-time output streaming and return exit code"""
30
+ try:
31
+ process = await asyncio.create_subprocess_exec(*cmd)
32
+ return await process.wait()
33
+ except FileNotFoundError as e:
34
+ raise RuntimeError(f"Command not found: {cmd[0]}") from e
35
+
36
+
37
+ async def minikube_cluster_exists(minikube: str, cluster_name: str) -> bool:
38
+ """Check if a Minikube cluster exists."""
39
+ if not minikube_installed(minikube):
40
+ return False
41
+
42
+ try:
43
+ returncode, _, _ = await run_command([minikube, "status", "-p", cluster_name])
44
+ return returncode == 0
45
+ except RuntimeError:
46
+ return False
47
+
48
+
49
+ async def kind_cluster_exists(kind: str, cluster_name: str) -> bool:
50
+ """Check if a Kind cluster exists."""
51
+ if not kind_installed(kind):
52
+ return False
53
+
54
+ try:
55
+ returncode, _, _ = await run_command([kind, "get", "kubeconfig", "--name", cluster_name])
56
+ return returncode == 0
57
+ except RuntimeError:
58
+ return False
59
+
60
+
61
+ async def delete_minikube_cluster(minikube: str, cluster_name: str) -> bool:
62
+ """Delete a Minikube cluster."""
63
+ if not minikube_installed(minikube):
64
+ raise RuntimeError(f"{minikube} is not installed or not found in PATH.")
65
+
66
+ if not await minikube_cluster_exists(minikube, cluster_name):
67
+ return True # Already deleted, consider it successful
68
+
69
+ returncode = await run_command_with_output([minikube, "delete", "-p", cluster_name])
70
+
71
+ if returncode == 0:
72
+ return True
73
+ else:
74
+ raise RuntimeError(f"Failed to delete Minikube cluster '{cluster_name}'")
75
+
76
+
77
+ async def delete_kind_cluster(kind: str, cluster_name: str) -> bool:
78
+ """Delete a Kind cluster."""
79
+ if not kind_installed(kind):
80
+ raise RuntimeError(f"{kind} is not installed or not found in PATH.")
81
+
82
+ if not await kind_cluster_exists(kind, cluster_name):
83
+ return True # Already deleted, consider it successful
84
+
85
+ returncode = await run_command_with_output([kind, "delete", "cluster", "--name", cluster_name])
86
+
87
+ if returncode == 0:
88
+ return True
89
+ else:
90
+ raise RuntimeError(f"Failed to delete Kind cluster '{cluster_name}'")
91
+
92
+
93
+ async def create_minikube_cluster(
94
+ minikube: str, cluster_name: str, extra_args: Optional[list[str]] = None, force_recreate: bool = False
95
+ ) -> bool:
96
+ """Create a Minikube cluster."""
97
+ if extra_args is None:
98
+ extra_args = []
99
+
100
+ if not minikube_installed(minikube):
101
+ raise RuntimeError(f"{minikube} is not installed or not found in PATH.")
102
+
103
+ # Check if cluster already exists
104
+ cluster_exists = await minikube_cluster_exists(minikube, cluster_name)
105
+
106
+ if cluster_exists:
107
+ if not force_recreate:
108
+ raise RuntimeError(f"Minikube cluster '{cluster_name}' already exists.")
109
+ else:
110
+ if not await delete_minikube_cluster(minikube, cluster_name):
111
+ return False
112
+
113
+ has_cpus_flag = any(a == "--cpus" or a.startswith("--cpus=") for a in extra_args)
114
+ if not has_cpus_flag:
115
+ try:
116
+ rc, out, _ = await run_command([minikube, "config", "get", "cpus"])
117
+ has_config_cpus = rc == 0 and out.strip().isdigit() and int(out.strip()) > 0
118
+ except RuntimeError:
119
+ # If we cannot query minikube (e.g., not installed in test env), default CPUs
120
+ has_config_cpus = False
121
+ if not has_config_cpus:
122
+ extra_args.append("--cpus=4")
123
+
124
+ command = [
125
+ minikube,
126
+ "start",
127
+ "--profile",
128
+ cluster_name,
129
+ "--extra-config=apiserver.service-node-port-range=8000-9000",
130
+ ]
131
+ command.extend(extra_args)
132
+
133
+ returncode = await run_command_with_output(command)
134
+
135
+ if returncode == 0:
136
+ return True
137
+ else:
138
+ raise RuntimeError(f"Failed to create Minikube cluster '{cluster_name}'")
139
+
140
+
141
+ async def create_kind_cluster(
142
+ kind: str, cluster_name: str, extra_args: Optional[list[str]] = None, force_recreate: bool = False
143
+ ) -> bool:
144
+ """Create a Kind cluster."""
145
+ if extra_args is None:
146
+ extra_args = []
147
+
148
+ if not kind_installed(kind):
149
+ raise RuntimeError(f"{kind} is not installed or not found in PATH.")
150
+
151
+ # Check if cluster already exists
152
+ cluster_exists = await kind_cluster_exists(kind, cluster_name)
153
+
154
+ if cluster_exists:
155
+ if not force_recreate:
156
+ raise RuntimeError(f"Kind cluster '{cluster_name}' already exists.")
157
+ else:
158
+ if not await delete_kind_cluster(kind, cluster_name):
159
+ return False
160
+
161
+ cluster_config = """kind: Cluster
162
+ apiVersion: kind.x-k8s.io/v1alpha4
163
+ kubeadmConfigPatches:
164
+ - |
165
+ kind: ClusterConfiguration
166
+ apiServer:
167
+ extraArgs:
168
+ "service-node-port-range": "3000-32767"
169
+ - |
170
+ kind: InitConfiguration
171
+ nodeRegistration:
172
+ kubeletExtraArgs:
173
+ node-labels: "ingress-ready=true"
174
+ nodes:
175
+ - role: control-plane
176
+ extraPortMappings:
177
+ - containerPort: 80
178
+ hostPort: 5080
179
+ protocol: TCP
180
+ - containerPort: 30010
181
+ hostPort: 8082
182
+ protocol: TCP
183
+ - containerPort: 30011
184
+ hostPort: 8083
185
+ protocol: TCP
186
+ - containerPort: 443
187
+ hostPort: 5443
188
+ protocol: TCP
189
+ """
190
+
191
+ command = [kind, "create", "cluster", "--name", cluster_name, "--config=/dev/stdin"]
192
+ command.extend(extra_args)
193
+
194
+ kind_process = await asyncio.create_subprocess_exec(*command, stdin=asyncio.subprocess.PIPE)
195
+
196
+ await kind_process.communicate(input=cluster_config.encode())
197
+
198
+ if kind_process.returncode == 0:
199
+ return True
200
+ else:
201
+ raise RuntimeError(f"Failed to create Kind cluster '{cluster_name}'")
@@ -0,0 +1,395 @@
1
+ import asyncio
2
+ from unittest.mock import AsyncMock, patch
3
+
4
+ import pytest
5
+
6
+ from jumpstarter_kubernetes.cluster import (
7
+ create_kind_cluster,
8
+ create_minikube_cluster,
9
+ delete_kind_cluster,
10
+ delete_minikube_cluster,
11
+ kind_cluster_exists,
12
+ kind_installed,
13
+ minikube_cluster_exists,
14
+ minikube_installed,
15
+ run_command,
16
+ run_command_with_output,
17
+ )
18
+
19
+
20
+ class TestClusterDetection:
21
+ """Test cluster tool detection functions."""
22
+
23
+ @patch("jumpstarter_kubernetes.cluster.shutil.which")
24
+ def test_kind_installed_true(self, mock_which):
25
+ mock_which.return_value = "/usr/local/bin/kind"
26
+ assert kind_installed("kind") is True
27
+ mock_which.assert_called_once_with("kind")
28
+
29
+ @patch("jumpstarter_kubernetes.cluster.shutil.which")
30
+ def test_kind_installed_false(self, mock_which):
31
+ mock_which.return_value = None
32
+ assert kind_installed("kind") is False
33
+ mock_which.assert_called_once_with("kind")
34
+
35
+ @patch("jumpstarter_kubernetes.cluster.shutil.which")
36
+ def test_minikube_installed_true(self, mock_which):
37
+ mock_which.return_value = "/usr/local/bin/minikube"
38
+ assert minikube_installed("minikube") is True
39
+ mock_which.assert_called_once_with("minikube")
40
+
41
+ @patch("jumpstarter_kubernetes.cluster.shutil.which")
42
+ def test_minikube_installed_false(self, mock_which):
43
+ mock_which.return_value = None
44
+ assert minikube_installed("minikube") is False
45
+ mock_which.assert_called_once_with("minikube")
46
+
47
+
48
+ class TestCommandExecution:
49
+ """Test command execution utilities."""
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_run_command_success(self):
53
+ with patch("asyncio.create_subprocess_exec") as mock_subprocess:
54
+ mock_process = AsyncMock()
55
+ mock_process.communicate.return_value = (b"output\n", b"")
56
+ mock_process.returncode = 0
57
+ mock_subprocess.return_value = mock_process
58
+
59
+ returncode, stdout, stderr = await run_command(["echo", "test"])
60
+
61
+ assert returncode == 0
62
+ assert stdout == "output"
63
+ assert stderr == ""
64
+ mock_subprocess.assert_called_once_with(
65
+ "echo", "test", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
66
+ )
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_run_command_failure(self):
70
+ with patch("asyncio.create_subprocess_exec") as mock_subprocess:
71
+ mock_process = AsyncMock()
72
+ mock_process.communicate.return_value = (b"", b"error message\n")
73
+ mock_process.returncode = 1
74
+ mock_subprocess.return_value = mock_process
75
+
76
+ returncode, stdout, stderr = await run_command(["false"])
77
+
78
+ assert returncode == 1
79
+ assert stdout == ""
80
+ assert stderr == "error message"
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_run_command_not_found(self):
84
+ with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")):
85
+ with pytest.raises(RuntimeError, match="Command not found: nonexistent"):
86
+ await run_command(["nonexistent"])
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_run_command_with_output_success(self):
90
+ with patch("asyncio.create_subprocess_exec") as mock_subprocess:
91
+ mock_process = AsyncMock()
92
+ mock_process.wait.return_value = 0
93
+ mock_subprocess.return_value = mock_process
94
+
95
+ returncode = await run_command_with_output(["echo", "test"])
96
+
97
+ assert returncode == 0
98
+ mock_subprocess.assert_called_once_with("echo", "test")
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_run_command_with_output_not_found(self):
102
+ with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("command not found")):
103
+ with pytest.raises(RuntimeError, match="Command not found: nonexistent"):
104
+ await run_command_with_output(["nonexistent"])
105
+
106
+
107
+ class TestClusterExistence:
108
+ """Test cluster existence checking functions."""
109
+
110
+ @pytest.mark.asyncio
111
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
112
+ @patch("jumpstarter_kubernetes.cluster.run_command")
113
+ async def test_kind_cluster_exists_true(self, mock_run_command, mock_kind_installed):
114
+ mock_kind_installed.return_value = True
115
+ mock_run_command.return_value = (0, "", "")
116
+
117
+ result = await kind_cluster_exists("kind", "test-cluster")
118
+
119
+ assert result is True
120
+ mock_run_command.assert_called_once_with(["kind", "get", "kubeconfig", "--name", "test-cluster"])
121
+
122
+ @pytest.mark.asyncio
123
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
124
+ @patch("jumpstarter_kubernetes.cluster.run_command")
125
+ async def test_kind_cluster_exists_false(self, mock_run_command, mock_kind_installed):
126
+ mock_kind_installed.return_value = True
127
+ mock_run_command.return_value = (1, "", "cluster not found")
128
+
129
+ result = await kind_cluster_exists("kind", "test-cluster")
130
+
131
+ assert result is False
132
+
133
+ @pytest.mark.asyncio
134
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
135
+ async def test_kind_cluster_exists_kind_not_installed(self, mock_kind_installed):
136
+ mock_kind_installed.return_value = False
137
+
138
+ result = await kind_cluster_exists("kind", "test-cluster")
139
+
140
+ assert result is False
141
+
142
+ @pytest.mark.asyncio
143
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
144
+ @patch("jumpstarter_kubernetes.cluster.run_command")
145
+ async def test_kind_cluster_exists_runtime_error(self, mock_run_command, mock_kind_installed):
146
+ mock_kind_installed.return_value = True
147
+ mock_run_command.side_effect = RuntimeError("Command failed")
148
+
149
+ result = await kind_cluster_exists("kind", "test-cluster")
150
+
151
+ assert result is False
152
+
153
+ @pytest.mark.asyncio
154
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
155
+ @patch("jumpstarter_kubernetes.cluster.run_command")
156
+ async def test_minikube_cluster_exists_true(self, mock_run_command, mock_minikube_installed):
157
+ mock_minikube_installed.return_value = True
158
+ mock_run_command.return_value = (0, "", "")
159
+
160
+ result = await minikube_cluster_exists("minikube", "test-cluster")
161
+
162
+ assert result is True
163
+ mock_run_command.assert_called_once_with(["minikube", "status", "-p", "test-cluster"])
164
+
165
+ @pytest.mark.asyncio
166
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
167
+ async def test_minikube_cluster_exists_minikube_not_installed(self, mock_minikube_installed):
168
+ mock_minikube_installed.return_value = False
169
+
170
+ result = await minikube_cluster_exists("minikube", "test-cluster")
171
+
172
+ assert result is False
173
+
174
+
175
+ class TestKindClusterOperations:
176
+ """Test Kind cluster creation and deletion."""
177
+
178
+ @pytest.mark.asyncio
179
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
180
+ @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists")
181
+ @patch("asyncio.create_subprocess_exec")
182
+ async def test_create_kind_cluster_success(self, mock_subprocess, mock_cluster_exists, mock_kind_installed):
183
+ mock_kind_installed.return_value = True
184
+ mock_cluster_exists.return_value = False
185
+
186
+ mock_process = AsyncMock()
187
+ mock_process.returncode = 0
188
+ mock_process.communicate.return_value = (b"", b"")
189
+ mock_subprocess.return_value = mock_process
190
+
191
+ result = await create_kind_cluster("kind", "test-cluster")
192
+
193
+ assert result is True
194
+ mock_subprocess.assert_called_once()
195
+ args, kwargs = mock_subprocess.call_args
196
+ assert args[0] == "kind"
197
+ assert args[1] == "create"
198
+ assert args[2] == "cluster"
199
+ assert "--name" in args
200
+ assert "test-cluster" in args
201
+ assert kwargs["stdin"] == asyncio.subprocess.PIPE
202
+
203
+ @pytest.mark.asyncio
204
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
205
+ async def test_create_kind_cluster_not_installed(self, mock_kind_installed):
206
+ mock_kind_installed.return_value = False
207
+
208
+ with pytest.raises(RuntimeError, match="kind is not installed"):
209
+ await create_kind_cluster("kind", "test-cluster")
210
+
211
+ @pytest.mark.asyncio
212
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
213
+ @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists")
214
+ async def test_create_kind_cluster_already_exists(self, mock_cluster_exists, mock_kind_installed):
215
+ mock_kind_installed.return_value = True
216
+ mock_cluster_exists.return_value = True
217
+
218
+ with pytest.raises(RuntimeError, match="Kind cluster 'test-cluster' already exists"):
219
+ await create_kind_cluster("kind", "test-cluster")
220
+
221
+ @pytest.mark.asyncio
222
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
223
+ @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists")
224
+ @patch("jumpstarter_kubernetes.cluster.delete_kind_cluster")
225
+ @patch("asyncio.create_subprocess_exec")
226
+ async def test_create_kind_cluster_force_recreate(
227
+ self, mock_subprocess, mock_delete, mock_cluster_exists, mock_kind_installed
228
+ ):
229
+ mock_kind_installed.return_value = True
230
+ mock_cluster_exists.return_value = True
231
+ mock_delete.return_value = True
232
+
233
+ mock_process = AsyncMock()
234
+ mock_process.returncode = 0
235
+ mock_process.communicate.return_value = (b"", b"")
236
+ mock_subprocess.return_value = mock_process
237
+
238
+ result = await create_kind_cluster("kind", "test-cluster", force_recreate=True)
239
+
240
+ assert result is True
241
+ mock_delete.assert_called_once_with("kind", "test-cluster")
242
+
243
+ @pytest.mark.asyncio
244
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
245
+ @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists")
246
+ @patch("asyncio.create_subprocess_exec")
247
+ async def test_create_kind_cluster_with_extra_args(self, mock_subprocess, mock_cluster_exists, mock_kind_installed):
248
+ mock_kind_installed.return_value = True
249
+ mock_cluster_exists.return_value = False
250
+
251
+ mock_process = AsyncMock()
252
+ mock_process.returncode = 0
253
+ mock_process.communicate.return_value = (b"", b"")
254
+ mock_subprocess.return_value = mock_process
255
+
256
+ result = await create_kind_cluster("kind", "test-cluster", extra_args=["--verbosity=1"])
257
+
258
+ assert result is True
259
+ args, _ = mock_subprocess.call_args
260
+ assert "--verbosity=1" in args
261
+
262
+ @pytest.mark.asyncio
263
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
264
+ @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists")
265
+ @patch("jumpstarter_kubernetes.cluster.run_command_with_output")
266
+ async def test_delete_kind_cluster_success(self, mock_run_command, mock_cluster_exists, mock_kind_installed):
267
+ mock_kind_installed.return_value = True
268
+ mock_cluster_exists.return_value = True
269
+ mock_run_command.return_value = 0
270
+
271
+ result = await delete_kind_cluster("kind", "test-cluster")
272
+
273
+ assert result is True
274
+ mock_run_command.assert_called_once_with(["kind", "delete", "cluster", "--name", "test-cluster"])
275
+
276
+ @pytest.mark.asyncio
277
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
278
+ async def test_delete_kind_cluster_not_installed(self, mock_kind_installed):
279
+ mock_kind_installed.return_value = False
280
+
281
+ with pytest.raises(RuntimeError, match="kind is not installed"):
282
+ await delete_kind_cluster("kind", "test-cluster")
283
+
284
+ @pytest.mark.asyncio
285
+ @patch("jumpstarter_kubernetes.cluster.kind_installed")
286
+ @patch("jumpstarter_kubernetes.cluster.kind_cluster_exists")
287
+ async def test_delete_kind_cluster_already_deleted(self, mock_cluster_exists, mock_kind_installed):
288
+ mock_kind_installed.return_value = True
289
+ mock_cluster_exists.return_value = False
290
+
291
+ result = await delete_kind_cluster("kind", "test-cluster")
292
+
293
+ assert result is True # Already deleted, consider successful
294
+
295
+
296
+ class TestMinikubeClusterOperations:
297
+ """Test Minikube cluster creation and deletion."""
298
+
299
+ @pytest.mark.asyncio
300
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
301
+ @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists")
302
+ @patch("jumpstarter_kubernetes.cluster.run_command_with_output")
303
+ async def test_create_minikube_cluster_success(
304
+ self, mock_run_command, mock_cluster_exists, mock_minikube_installed
305
+ ):
306
+ mock_minikube_installed.return_value = True
307
+ mock_cluster_exists.return_value = False
308
+ mock_run_command.return_value = 0
309
+
310
+ result = await create_minikube_cluster("minikube", "test-cluster")
311
+
312
+ assert result is True
313
+ mock_run_command.assert_called_once()
314
+ args = mock_run_command.call_args[0][0]
315
+ assert args[0] == "minikube"
316
+ assert args[1] == "start"
317
+ assert "--profile" in args
318
+ assert "test-cluster" in args
319
+ assert "--extra-config=apiserver.service-node-port-range=8000-9000" in args
320
+
321
+ @pytest.mark.asyncio
322
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
323
+ async def test_create_minikube_cluster_not_installed(self, mock_minikube_installed):
324
+ mock_minikube_installed.return_value = False
325
+
326
+ with pytest.raises(RuntimeError, match="minikube is not installed"):
327
+ await create_minikube_cluster("minikube", "test-cluster")
328
+
329
+ @pytest.mark.asyncio
330
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
331
+ @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists")
332
+ async def test_create_minikube_cluster_already_exists(self, mock_cluster_exists, mock_minikube_installed):
333
+ mock_minikube_installed.return_value = True
334
+ mock_cluster_exists.return_value = True
335
+
336
+ with pytest.raises(RuntimeError, match="Minikube cluster 'test-cluster' already exists"):
337
+ await create_minikube_cluster("minikube", "test-cluster")
338
+
339
+ @pytest.mark.asyncio
340
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
341
+ @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists")
342
+ @patch("jumpstarter_kubernetes.cluster.run_command_with_output")
343
+ async def test_create_minikube_cluster_with_extra_args(
344
+ self, mock_run_command, mock_cluster_exists, mock_minikube_installed
345
+ ):
346
+ mock_minikube_installed.return_value = True
347
+ mock_cluster_exists.return_value = False
348
+ mock_run_command.return_value = 0
349
+
350
+ result = await create_minikube_cluster("minikube", "test-cluster", extra_args=["--memory=4096"])
351
+
352
+ assert result is True
353
+ args = mock_run_command.call_args[0][0]
354
+ assert "--memory=4096" in args
355
+
356
+ @pytest.mark.asyncio
357
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
358
+ @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists")
359
+ @patch("jumpstarter_kubernetes.cluster.run_command_with_output")
360
+ async def test_delete_minikube_cluster_success(
361
+ self, mock_run_command, mock_cluster_exists, mock_minikube_installed
362
+ ):
363
+ mock_minikube_installed.return_value = True
364
+ mock_cluster_exists.return_value = True
365
+ mock_run_command.return_value = 0
366
+
367
+ result = await delete_minikube_cluster("minikube", "test-cluster")
368
+
369
+ assert result is True
370
+ mock_run_command.assert_called_once_with(["minikube", "delete", "-p", "test-cluster"])
371
+
372
+ @pytest.mark.asyncio
373
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
374
+ @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists")
375
+ async def test_delete_minikube_cluster_already_deleted(self, mock_cluster_exists, mock_minikube_installed):
376
+ mock_minikube_installed.return_value = True
377
+ mock_cluster_exists.return_value = False
378
+
379
+ result = await delete_minikube_cluster("minikube", "test-cluster")
380
+
381
+ assert result is True # Already deleted, consider successful
382
+
383
+ @pytest.mark.asyncio
384
+ @patch("jumpstarter_kubernetes.cluster.minikube_installed")
385
+ @patch("jumpstarter_kubernetes.cluster.minikube_cluster_exists")
386
+ @patch("jumpstarter_kubernetes.cluster.run_command_with_output")
387
+ async def test_delete_minikube_cluster_failure(
388
+ self, mock_run_command, mock_cluster_exists, mock_minikube_installed
389
+ ):
390
+ mock_minikube_installed.return_value = True
391
+ mock_cluster_exists.return_value = True
392
+ mock_run_command.return_value = 1
393
+
394
+ with pytest.raises(RuntimeError, match="Failed to delete Minikube cluster 'test-cluster'"):
395
+ await delete_minikube_cluster("minikube", "test-cluster")
@@ -0,0 +1,32 @@
1
+ from datetime import datetime, timezone
2
+
3
+
4
+ def time_since(t_str: str):
5
+ # Format the elapsed time in a readable way
6
+ t = datetime.strptime(t_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
7
+ now = datetime.now(timezone.utc)
8
+ elapsed = now - t
9
+
10
+ # Format the elapsed time in a readable way
11
+ if elapsed.total_seconds() < 60:
12
+ return f"{int(elapsed.total_seconds())}s"
13
+ elif elapsed.total_seconds() < 3600:
14
+ minutes = int(elapsed.total_seconds() // 60)
15
+ seconds = int(elapsed.total_seconds() % 60)
16
+ return f"{minutes}m{seconds}s" if seconds > 0 else f"{minutes}m"
17
+ elif elapsed.total_seconds() < 86400:
18
+ hours = int(elapsed.total_seconds() // 3600)
19
+ minutes = int((elapsed.total_seconds() % 3600) // 60)
20
+ return f"{hours}h{minutes}m" if minutes > 0 and hours < 2 else f"{hours}h"
21
+ elif elapsed.total_seconds() < 2592000:
22
+ days = elapsed.days
23
+ hours = int((elapsed.total_seconds() % 86400) // 3600)
24
+ return f"{days}d{hours}h" if hours > 0 else f"{days}d"
25
+ elif elapsed.total_seconds() < 31536000:
26
+ months = int(elapsed.days / 30)
27
+ days = elapsed.days % 30
28
+ return f"{months}mo{days}d" if days > 0 else f"{months}mo"
29
+ else:
30
+ years = int(elapsed.days / 365)
31
+ months = int((elapsed.days % 365) / 30)
32
+ return f"{years}y{months}mo" if months > 0 else f"{years}y"
@@ -5,6 +5,7 @@ from typing import Literal
5
5
  from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference
6
6
  from pydantic import Field
7
7
 
8
+ from .datetime import time_since
8
9
  from .json import JsonBaseModel
9
10
  from .list import V1Alpha1List
10
11
  from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference
@@ -57,6 +58,47 @@ class V1Alpha1Exporter(JsonBaseModel):
57
58
  ),
58
59
  )
59
60
 
61
+ @classmethod
62
+ def rich_add_columns(cls, table, devices: bool = False):
63
+ if devices:
64
+ table.add_column("NAME")
65
+ table.add_column("ENDPOINT")
66
+ table.add_column("AGE")
67
+ table.add_column("LABELS")
68
+ table.add_column("UUID")
69
+ else:
70
+ table.add_column("NAME")
71
+ table.add_column("ENDPOINT")
72
+ table.add_column("DEVICES")
73
+ table.add_column("AGE")
74
+
75
+ def rich_add_rows(self, table, devices: bool = False):
76
+ if devices:
77
+ if self.status is not None:
78
+ for d in self.status.devices:
79
+ labels = []
80
+ if d.labels is not None:
81
+ for label in d.labels:
82
+ labels.append(f"{label}:{str(d.labels[label])}")
83
+ table.add_row(
84
+ self.metadata.name,
85
+ self.status.endpoint,
86
+ time_since(self.metadata.creation_timestamp),
87
+ ",".join(labels),
88
+ d.uuid,
89
+ )
90
+
91
+ else:
92
+ table.add_row(
93
+ self.metadata.name,
94
+ self.status.endpoint,
95
+ str(len(self.status.devices) if self.status and self.status.devices else 0),
96
+ time_since(self.metadata.creation_timestamp),
97
+ )
98
+
99
+ def rich_add_names(self, names):
100
+ names.append(f"exporter.jumpstarter.dev/{self.metadata.name}")
101
+
60
102
 
61
103
  class V1Alpha1ExporterList(V1Alpha1List[V1Alpha1Exporter]):
62
104
  kind: Literal["ExporterList"] = Field(default="ExporterList")
@@ -65,6 +107,18 @@ class V1Alpha1ExporterList(V1Alpha1List[V1Alpha1Exporter]):
65
107
  def from_dict(dict: dict):
66
108
  return V1Alpha1ExporterList(items=[V1Alpha1Exporter.from_dict(c) for c in dict["items"]])
67
109
 
110
+ @classmethod
111
+ def rich_add_columns(cls, table, **kwargs):
112
+ V1Alpha1Exporter.rich_add_columns(table, **kwargs)
113
+
114
+ def rich_add_rows(self, table, **kwargs):
115
+ for exporter in self.items:
116
+ exporter.rich_add_rows(table, **kwargs)
117
+
118
+ def rich_add_names(self, names):
119
+ for exporter in self.items:
120
+ exporter.rich_add_names(names)
121
+
68
122
 
69
123
  class ExportersV1Alpha1Api(AbstractAsyncCustomObjectApi):
70
124
  """Interact with the exporters custom resource API"""
@@ -4,6 +4,7 @@ from typing import Literal, Optional
4
4
 
5
5
 
6
6
  def helm_installed(name: str) -> bool:
7
+ """Check if Helm is installed and available in the PATH."""
7
8
  return shutil.which(name) is not None
8
9
 
9
10
 
@@ -20,8 +21,6 @@ async def install_helm_chart(
20
21
  context: Optional[str],
21
22
  helm: Optional[str] = "helm",
22
23
  ):
23
- grpc_port = grpc_endpoint.split(":")[1]
24
- router_port = router_endpoint.split(":")[1]
25
24
  args = [
26
25
  helm,
27
26
  "upgrade",
@@ -42,10 +41,6 @@ async def install_helm_chart(
42
41
  "--set",
43
42
  f"jumpstarter-controller.grpc.nodeport.enabled={'true' if mode == 'nodeport' else 'false'}",
44
43
  "--set",
45
- f"jumpstarter-controller.grpc.nodeport.port={grpc_port}",
46
- "--set",
47
- f"jumpstarter-controller.grpc.nodeport.routerPort={router_port}",
48
- "--set",
49
44
  f"jumpstarter-controller.grpc.mode={mode}",
50
45
  "--version",
51
46
  version,
@@ -63,3 +58,28 @@ async def install_helm_chart(
63
58
  # Attempt to install Jumpstarter using Helm
64
59
  process = await asyncio.create_subprocess_exec(*args)
65
60
  await process.wait()
61
+
62
+
63
+ async def uninstall_helm_chart(
64
+ name: str, namespace: str, kubeconfig: Optional[str], context: Optional[str], helm: Optional[str] = "helm"
65
+ ):
66
+ args = [
67
+ helm,
68
+ "uninstall",
69
+ name,
70
+ "--namespace",
71
+ namespace,
72
+ "--wait",
73
+ ]
74
+
75
+ if kubeconfig is not None:
76
+ args.append("--kubeconfig")
77
+ args.append(kubeconfig)
78
+
79
+ if context is not None:
80
+ args.append("--kube-context")
81
+ args.append(context)
82
+
83
+ # Attempt to install Jumpstarter using Helm
84
+ process = await asyncio.create_subprocess_exec(*args)
85
+ await process.wait()
@@ -3,6 +3,7 @@ from typing import Literal, Optional
3
3
  from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference
4
4
  from pydantic import Field
5
5
 
6
+ from .datetime import time_since
6
7
  from .json import JsonBaseModel
7
8
  from .list import V1Alpha1List
8
9
  from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference
@@ -76,6 +77,56 @@ class V1Alpha1Lease(JsonBaseModel):
76
77
  ),
77
78
  )
78
79
 
80
+ @classmethod
81
+ def rich_add_columns(cls, table):
82
+ table.add_column("NAME")
83
+ table.add_column("CLIENT")
84
+ table.add_column("SELECTOR")
85
+ table.add_column("EXPORTER")
86
+ table.add_column("DURATION")
87
+ table.add_column("STATUS")
88
+ table.add_column("REASON")
89
+ table.add_column("BEGIN")
90
+ table.add_column("END")
91
+ table.add_column("AGE")
92
+
93
+ def get_reason(self):
94
+ condition = self.status.conditions[-1] if len(self.status.conditions) > 0 else None
95
+ reason = condition.reason if condition is not None else "Unknown"
96
+ status = condition.status if condition is not None else "False"
97
+ if reason == "Ready":
98
+ if status == "True":
99
+ return "Ready"
100
+ else:
101
+ return "Waiting"
102
+ elif reason == "Expired":
103
+ if status == "True":
104
+ return "Expired"
105
+ else:
106
+ return "Complete"
107
+ else:
108
+ return reason
109
+
110
+ def rich_add_rows(self, table):
111
+ selectors = []
112
+ for label in self.spec.selector.match_labels:
113
+ selectors.append(f"{label}:{str(self.spec.selector.match_labels[label])}")
114
+ table.add_row(
115
+ self.metadata.name,
116
+ self.spec.client.name if self.spec.client is not None else "",
117
+ ",".join(selectors) if len(selectors) > 0 else "*",
118
+ self.status.exporter.name if self.status.exporter is not None else "",
119
+ self.spec.duration,
120
+ "Ended" if self.status.ended else "InProgress",
121
+ self.get_reason(),
122
+ self.status.begin_time if self.status.begin_time is not None else "",
123
+ self.status.end_time if self.status.end_time is not None else "",
124
+ time_since(self.metadata.creation_timestamp),
125
+ )
126
+
127
+ def rich_add_names(self, names):
128
+ names.append(f"lease.jumpstarter.dev/{self.metadata.name}")
129
+
79
130
 
80
131
  class V1Alpha1LeaseList(V1Alpha1List[V1Alpha1Lease]):
81
132
  kind: Literal["LeaseList"] = Field(default="LeaseList")
@@ -84,6 +135,18 @@ class V1Alpha1LeaseList(V1Alpha1List[V1Alpha1Lease]):
84
135
  def from_dict(dict: dict):
85
136
  return V1Alpha1LeaseList(items=[V1Alpha1Lease.from_dict(c) for c in dict["items"]])
86
137
 
138
+ @classmethod
139
+ def rich_add_columns(cls, table):
140
+ V1Alpha1Lease.rich_add_columns(table)
141
+
142
+ def rich_add_rows(self, table):
143
+ for lease in self.items:
144
+ lease.rich_add_rows(table)
145
+
146
+ def rich_add_names(self, names):
147
+ for lease in self.items:
148
+ lease.rich_add_names(names)
149
+
87
150
 
88
151
  class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi):
89
152
  """Interact with the leases custom resource API"""
@@ -12,7 +12,7 @@ readme = "README.md"
12
12
  license = "Apache-2.0"
13
13
  requires-python = ">=3.11"
14
14
  dependencies = [
15
- "jumpstarter==0.6.0",
15
+ "jumpstarter==0.7.1",
16
16
  "pydantic>=2.8.2",
17
17
  "kubernetes>=31.0.0",
18
18
  "kubernetes-asyncio>=31.1.0",