localargo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localargo/__about__.py +6 -0
- localargo/__init__.py +6 -0
- localargo/__main__.py +11 -0
- localargo/cli/__init__.py +49 -0
- localargo/cli/commands/__init__.py +5 -0
- localargo/cli/commands/app.py +150 -0
- localargo/cli/commands/cluster.py +312 -0
- localargo/cli/commands/debug.py +478 -0
- localargo/cli/commands/port_forward.py +311 -0
- localargo/cli/commands/secrets.py +300 -0
- localargo/cli/commands/sync.py +291 -0
- localargo/cli/commands/template.py +288 -0
- localargo/cli/commands/up.py +341 -0
- localargo/config/__init__.py +15 -0
- localargo/config/manifest.py +520 -0
- localargo/config/store.py +66 -0
- localargo/core/__init__.py +6 -0
- localargo/core/apps.py +330 -0
- localargo/core/argocd.py +509 -0
- localargo/core/catalog.py +284 -0
- localargo/core/cluster.py +149 -0
- localargo/core/k8s.py +140 -0
- localargo/eyecandy/__init__.py +15 -0
- localargo/eyecandy/progress_steps.py +283 -0
- localargo/eyecandy/table_renderer.py +154 -0
- localargo/eyecandy/tables.py +57 -0
- localargo/logging.py +99 -0
- localargo/manager.py +232 -0
- localargo/providers/__init__.py +6 -0
- localargo/providers/base.py +146 -0
- localargo/providers/k3s.py +206 -0
- localargo/providers/kind.py +326 -0
- localargo/providers/registry.py +52 -0
- localargo/utils/__init__.py +4 -0
- localargo/utils/cli.py +231 -0
- localargo/utils/proc.py +148 -0
- localargo/utils/retry.py +58 -0
- localargo-0.1.0.dist-info/METADATA +149 -0
- localargo-0.1.0.dist-info/RECORD +42 -0
- localargo-0.1.0.dist-info/WHEEL +4 -0
- localargo-0.1.0.dist-info/entry_points.txt +2 -0
- localargo-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,326 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
"""KinD (Kubernetes in Docker) provider implementation."""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import shutil
|
9
|
+
import subprocess
|
10
|
+
import tempfile
|
11
|
+
from pathlib import Path
|
12
|
+
from typing import Any
|
13
|
+
|
14
|
+
from localargo.providers.base import (
|
15
|
+
ClusterCreationError,
|
16
|
+
ClusterOperationError,
|
17
|
+
ClusterProvider,
|
18
|
+
ProviderNotAvailableError,
|
19
|
+
)
|
20
|
+
from localargo.utils.cli import check_cli_availability, run_subprocess
|
21
|
+
|
22
|
+
|
23
|
+
class KindProvider(ClusterProvider):
|
24
|
+
"""KinD (Kubernetes in Docker) cluster provider."""
|
25
|
+
|
26
|
+
@property
|
27
|
+
def provider_name(self) -> str:
|
28
|
+
return "kind"
|
29
|
+
|
30
|
+
def _create_kind_config(self) -> str:
|
31
|
+
"""Create a kind cluster config with port mappings for ingress."""
|
32
|
+
return """kind: Cluster
|
33
|
+
apiVersion: kind.x-k8s.io/v1alpha4
|
34
|
+
nodes:
|
35
|
+
- role: control-plane
|
36
|
+
extraPortMappings:
|
37
|
+
- containerPort: 80
|
38
|
+
hostPort: 80
|
39
|
+
protocol: TCP
|
40
|
+
- containerPort: 443
|
41
|
+
hostPort: 443
|
42
|
+
protocol: TCP
|
43
|
+
"""
|
44
|
+
|
45
|
+
def is_available(self) -> bool:
|
46
|
+
"""Check if KinD, kubectl, and helm are installed and available."""
|
47
|
+
try:
|
48
|
+
# Check kind
|
49
|
+
kind_path = check_cli_availability("kind")
|
50
|
+
if not kind_path:
|
51
|
+
return False
|
52
|
+
result = subprocess.run(
|
53
|
+
[kind_path, "version"], capture_output=True, text=True, check=True
|
54
|
+
)
|
55
|
+
if "kind" not in result.stdout.lower():
|
56
|
+
return False
|
57
|
+
|
58
|
+
# Check kubectl
|
59
|
+
kubectl_path = check_cli_availability("kubectl")
|
60
|
+
if not kubectl_path:
|
61
|
+
return False
|
62
|
+
|
63
|
+
# Check helm
|
64
|
+
helm_path = shutil.which("helm")
|
65
|
+
return bool(helm_path)
|
66
|
+
except (subprocess.CalledProcessError, FileNotFoundError, RuntimeError):
|
67
|
+
return False
|
68
|
+
|
69
|
+
def create_cluster(self, **kwargs: Any) -> bool: # noqa: ARG002
|
70
|
+
"""Create a KinD cluster and install ArgoCD with nginx-ingress."""
|
71
|
+
if not self.is_available():
|
72
|
+
msg = (
|
73
|
+
"KinD, kubectl, and helm are required. Install from: "
|
74
|
+
"https://kind.sigs.k8s.io/, https://kubernetes.io/docs/tasks/tools/, "
|
75
|
+
"and https://helm.sh/"
|
76
|
+
)
|
77
|
+
raise ProviderNotAvailableError(msg)
|
78
|
+
|
79
|
+
try:
|
80
|
+
# Create cluster with port mappings for direct access
|
81
|
+
config_content = self._create_kind_config()
|
82
|
+
with tempfile.NamedTemporaryFile(
|
83
|
+
mode="w", suffix=".yaml", delete=False
|
84
|
+
) as config_file:
|
85
|
+
config_file.write(config_content)
|
86
|
+
config_file_path = config_file.name
|
87
|
+
|
88
|
+
cmd = [
|
89
|
+
"kind",
|
90
|
+
"create",
|
91
|
+
"cluster",
|
92
|
+
"--name",
|
93
|
+
self.name,
|
94
|
+
"--config",
|
95
|
+
config_file_path,
|
96
|
+
]
|
97
|
+
subprocess.run(cmd, check=True)
|
98
|
+
|
99
|
+
# Clean up temporary config file
|
100
|
+
Path(config_file_path).unlink(missing_ok=True)
|
101
|
+
|
102
|
+
# Wait for cluster to be ready
|
103
|
+
self._wait_for_cluster_ready(f"kind-{self.name}")
|
104
|
+
|
105
|
+
# Install nginx-ingress
|
106
|
+
self._install_nginx_ingress()
|
107
|
+
|
108
|
+
# Install ArgoCD
|
109
|
+
self._install_argocd()
|
110
|
+
|
111
|
+
except subprocess.CalledProcessError as e:
|
112
|
+
msg = f"Failed to create KinD cluster: {e}"
|
113
|
+
raise ClusterCreationError(msg) from e
|
114
|
+
|
115
|
+
return True
|
116
|
+
|
117
|
+
def delete_cluster(self, name: str | None = None) -> bool:
|
118
|
+
"""Delete a KinD cluster."""
|
119
|
+
cluster_name = name or self.name
|
120
|
+
try:
|
121
|
+
cmd = ["kind", "delete", "cluster", "--name", cluster_name]
|
122
|
+
subprocess.run(cmd, check=True) # Show output for debugging
|
123
|
+
except subprocess.CalledProcessError as e:
|
124
|
+
msg = f"Failed to delete KinD cluster '{cluster_name}': {e}"
|
125
|
+
raise ClusterOperationError(msg) from e
|
126
|
+
|
127
|
+
return True
|
128
|
+
|
129
|
+
def get_cluster_status(self, name: str | None = None) -> dict[str, Any]:
|
130
|
+
"""Get KinD cluster status information."""
|
131
|
+
cluster_name = name or self.name
|
132
|
+
context_name = f"kind-{cluster_name}"
|
133
|
+
|
134
|
+
try:
|
135
|
+
# Check if cluster exists
|
136
|
+
kind_path = shutil.which("kind")
|
137
|
+
if kind_path is None:
|
138
|
+
msg = "kind not found in PATH. Please ensure kind is installed and available."
|
139
|
+
raise RuntimeError(msg)
|
140
|
+
result = subprocess.run(
|
141
|
+
[kind_path, "get", "clusters"], capture_output=True, text=True, check=True
|
142
|
+
)
|
143
|
+
clusters = result.stdout.strip().split("\n")
|
144
|
+
exists = cluster_name in clusters
|
145
|
+
|
146
|
+
status = {
|
147
|
+
"provider": "kind",
|
148
|
+
"name": cluster_name,
|
149
|
+
"exists": exists,
|
150
|
+
"context": context_name,
|
151
|
+
"ready": False,
|
152
|
+
}
|
153
|
+
|
154
|
+
if exists:
|
155
|
+
# Check if context is accessible
|
156
|
+
try:
|
157
|
+
run_subprocess(["kubectl", "cluster-info", "--context", context_name])
|
158
|
+
status["ready"] = True
|
159
|
+
except subprocess.CalledProcessError:
|
160
|
+
pass
|
161
|
+
|
162
|
+
except subprocess.CalledProcessError as e:
|
163
|
+
msg = f"Failed to get cluster status: {e}"
|
164
|
+
raise ClusterOperationError(msg) from e
|
165
|
+
|
166
|
+
return status
|
167
|
+
|
168
|
+
def _wait_for_cluster_ready(
|
169
|
+
self, context_name: str | subprocess.Popen, timeout: int = 60
|
170
|
+
) -> None:
|
171
|
+
"""Wait for the cluster to be ready."""
|
172
|
+
if isinstance(context_name, str) or context_name is None:
|
173
|
+
context_name = f"kind-{self.name}"
|
174
|
+
super()._wait_for_cluster_ready(context_name, timeout)
|
175
|
+
|
176
|
+
def _install_nginx_ingress(self) -> None:
|
177
|
+
"""Install nginx-ingress controller."""
|
178
|
+
helm_path = shutil.which("helm")
|
179
|
+
kubectl_path = shutil.which("kubectl")
|
180
|
+
if helm_path is None:
|
181
|
+
msg = "helm not found in PATH. Please ensure helm is installed and available."
|
182
|
+
raise RuntimeError(msg)
|
183
|
+
if kubectl_path is None:
|
184
|
+
msg = (
|
185
|
+
"kubectl not found in PATH. Please ensure kubectl is installed and available."
|
186
|
+
)
|
187
|
+
raise RuntimeError(msg)
|
188
|
+
|
189
|
+
try:
|
190
|
+
# Add ingress-nginx helm repo
|
191
|
+
subprocess.run(
|
192
|
+
[
|
193
|
+
helm_path,
|
194
|
+
"repo",
|
195
|
+
"add",
|
196
|
+
"ingress-nginx",
|
197
|
+
"https://kubernetes.github.io/ingress-nginx",
|
198
|
+
],
|
199
|
+
check=False, # Allow failure if repo already exists
|
200
|
+
)
|
201
|
+
subprocess.run([helm_path, "repo", "update"], check=True)
|
202
|
+
|
203
|
+
# Install nginx-ingress using helm with kind-specific configuration
|
204
|
+
subprocess.run(
|
205
|
+
[
|
206
|
+
helm_path,
|
207
|
+
"upgrade",
|
208
|
+
"--install",
|
209
|
+
"ingress-nginx",
|
210
|
+
"ingress-nginx/ingress-nginx",
|
211
|
+
"--namespace",
|
212
|
+
"ingress-nginx",
|
213
|
+
"--create-namespace",
|
214
|
+
"--wait",
|
215
|
+
"--wait-for-jobs",
|
216
|
+
"--timeout=180s",
|
217
|
+
"--set",
|
218
|
+
"controller.hostNetwork=true",
|
219
|
+
"--set",
|
220
|
+
"controller.dnsPolicy=ClusterFirstWithHostNet",
|
221
|
+
"--set",
|
222
|
+
"controller.kind=DaemonSet",
|
223
|
+
"--set",
|
224
|
+
"controller.service.type=ClusterIP",
|
225
|
+
"--set",
|
226
|
+
"controller.extraArgs.enable-ssl-passthrough=true",
|
227
|
+
"--set",
|
228
|
+
"controller.extraArgs.enable-ssl-chain-completion=false",
|
229
|
+
"--set",
|
230
|
+
"controller.config.use-proxy-protocol=false",
|
231
|
+
"--set",
|
232
|
+
"controller.config.compute-full-forwarded-for=true",
|
233
|
+
"--set",
|
234
|
+
"controller.config.use-forwarded-headers=true",
|
235
|
+
"--set",
|
236
|
+
"controller.config.ssl-protocols=TLSv1.2 TLSv1.3",
|
237
|
+
"--set",
|
238
|
+
r"controller.nodeSelector.kubernetes\.io/os=linux",
|
239
|
+
"--set",
|
240
|
+
"controller.config.server-name-hash-bucket-size=256",
|
241
|
+
],
|
242
|
+
check=True,
|
243
|
+
)
|
244
|
+
|
245
|
+
# Wait for controller to be ready
|
246
|
+
subprocess.run(
|
247
|
+
[
|
248
|
+
kubectl_path,
|
249
|
+
"-n",
|
250
|
+
"ingress-nginx",
|
251
|
+
"rollout",
|
252
|
+
"status",
|
253
|
+
"daemonset/ingress-nginx-controller",
|
254
|
+
"--timeout=180s",
|
255
|
+
],
|
256
|
+
check=True,
|
257
|
+
)
|
258
|
+
|
259
|
+
except subprocess.CalledProcessError as e:
|
260
|
+
msg = f"Failed to install nginx-ingress: {e}"
|
261
|
+
raise ClusterCreationError(msg) from e
|
262
|
+
|
263
|
+
def _install_argocd(self) -> None:
|
264
|
+
"""Install ArgoCD using helm with ingress configuration."""
|
265
|
+
helm_path = shutil.which("helm")
|
266
|
+
if helm_path is None:
|
267
|
+
msg = "helm not found in PATH. Please ensure helm is installed and available."
|
268
|
+
raise RuntimeError(msg)
|
269
|
+
|
270
|
+
try:
|
271
|
+
# Add ArgoCD helm repo
|
272
|
+
subprocess.run(
|
273
|
+
[helm_path, "repo", "add", "argo", "https://argoproj.github.io/argo-helm"],
|
274
|
+
check=True,
|
275
|
+
)
|
276
|
+
subprocess.run([helm_path, "repo", "update"], check=True)
|
277
|
+
|
278
|
+
# Install ArgoCD with ingress enabled using proper SSL passthrough configuration
|
279
|
+
subprocess.run(
|
280
|
+
[
|
281
|
+
helm_path,
|
282
|
+
"upgrade",
|
283
|
+
"--install",
|
284
|
+
"argocd",
|
285
|
+
"argo/argo-cd",
|
286
|
+
"--namespace",
|
287
|
+
"argocd",
|
288
|
+
"--create-namespace",
|
289
|
+
"--wait",
|
290
|
+
"--wait-for-jobs",
|
291
|
+
"--timeout=180s",
|
292
|
+
"--set",
|
293
|
+
"server.ingress.enabled=true",
|
294
|
+
"--set",
|
295
|
+
"server.ingress.ingressClassName=nginx",
|
296
|
+
"--set",
|
297
|
+
"server.ingress.hostname=argocd.localtest.me",
|
298
|
+
"--set",
|
299
|
+
"server.ingress.annotations.nginx\\.ingress\\.kubernetes\\.io/"
|
300
|
+
"force-ssl-redirect=true",
|
301
|
+
"--set",
|
302
|
+
"server.ingress.annotations.nginx\\.ingress\\.kubernetes\\.io/"
|
303
|
+
"ssl-passthrough=true",
|
304
|
+
"--set",
|
305
|
+
"server.ingress.paths[0]=/",
|
306
|
+
"--set",
|
307
|
+
"server.ingress.pathType=Prefix",
|
308
|
+
"--set",
|
309
|
+
"server.ingress.tls=false",
|
310
|
+
"--set",
|
311
|
+
"server.extraArgs[0]=--insecure=false",
|
312
|
+
"--set",
|
313
|
+
"configs.params.server.insecure=false",
|
314
|
+
"--set",
|
315
|
+
"configs.params.server.grpc.web=true",
|
316
|
+
"--set",
|
317
|
+
"global.domain=argocd.localtest.me",
|
318
|
+
"--set",
|
319
|
+
"configs.cm.url=https://argocd.localtest.me",
|
320
|
+
],
|
321
|
+
check=True,
|
322
|
+
)
|
323
|
+
|
324
|
+
except subprocess.CalledProcessError as e:
|
325
|
+
msg = f"Failed to install ArgoCD: {e}"
|
326
|
+
raise ClusterCreationError(msg) from e
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
"""Provider registry for cluster management."""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
from typing import TYPE_CHECKING
|
9
|
+
|
10
|
+
from localargo.providers.k3s import K3sProvider
|
11
|
+
from localargo.providers.kind import KindProvider
|
12
|
+
|
13
|
+
if TYPE_CHECKING:
|
14
|
+
from localargo.providers.base import ClusterProvider
|
15
|
+
|
16
|
+
|
17
|
+
# Registry of available providers
|
18
|
+
PROVIDERS: dict[str, type[ClusterProvider]] = {
|
19
|
+
"kind": KindProvider,
|
20
|
+
"k3s": K3sProvider,
|
21
|
+
}
|
22
|
+
|
23
|
+
|
24
|
+
def get_provider(name: str) -> type[ClusterProvider]:
|
25
|
+
"""
|
26
|
+
Get a provider class by name.
|
27
|
+
|
28
|
+
Args:
|
29
|
+
name (str): Name of the provider (e.g., 'kind', 'k3s')
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
type[ClusterProvider]: Provider class
|
33
|
+
|
34
|
+
Raises:
|
35
|
+
ValueError: If provider name is unknown
|
36
|
+
"""
|
37
|
+
try:
|
38
|
+
return PROVIDERS[name]
|
39
|
+
except KeyError as err:
|
40
|
+
available = ", ".join(PROVIDERS.keys())
|
41
|
+
msg = f"Unknown provider: {name}. Available providers: {available}"
|
42
|
+
raise ValueError(msg) from err
|
43
|
+
|
44
|
+
|
45
|
+
def list_available_providers() -> list[str]:
|
46
|
+
"""
|
47
|
+
List names of all available providers.
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
list[str]: List of provider names
|
51
|
+
"""
|
52
|
+
return list(PROVIDERS.keys())
|
localargo/utils/cli.py
ADDED
@@ -0,0 +1,231 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
"""CLI validation and execution utilities."""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import shutil
|
9
|
+
import subprocess
|
10
|
+
from typing import Any
|
11
|
+
|
12
|
+
from localargo.logging import logger
|
13
|
+
|
14
|
+
|
15
|
+
def check_cli_availability(cli_name: str, error_msg: str | None = None) -> str | None:
|
16
|
+
"""Check if a CLI tool is available in PATH.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
cli_name (str): Name of the CLI tool to check
|
20
|
+
error_msg (str | None): Optional error message to raise if CLI not found
|
21
|
+
|
22
|
+
Returns:
|
23
|
+
str | None: Path to the CLI executable if found, None otherwise
|
24
|
+
|
25
|
+
Raises:
|
26
|
+
FileNotFoundError: If error_msg is provided and CLI is not found
|
27
|
+
"""
|
28
|
+
path = shutil.which(cli_name)
|
29
|
+
if not path and error_msg:
|
30
|
+
raise FileNotFoundError(error_msg)
|
31
|
+
return path
|
32
|
+
|
33
|
+
|
34
|
+
def ensure_argocd_available() -> str:
|
35
|
+
"""Ensure argocd CLI is available and return its path.
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
str: Path to the argocd CLI executable
|
39
|
+
|
40
|
+
Raises:
|
41
|
+
FileNotFoundError: If argocd CLI is not found
|
42
|
+
"""
|
43
|
+
argocd_path = check_cli_availability("argocd", "argocd CLI not found")
|
44
|
+
if not argocd_path:
|
45
|
+
msg = "argocd CLI not found"
|
46
|
+
raise FileNotFoundError(msg)
|
47
|
+
return argocd_path
|
48
|
+
|
49
|
+
|
50
|
+
def ensure_kubectl_available() -> str:
|
51
|
+
"""Ensure kubectl CLI is available and return its path.
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
str: Path to the kubectl CLI executable
|
55
|
+
|
56
|
+
Raises:
|
57
|
+
FileNotFoundError: If kubectl CLI is not found
|
58
|
+
"""
|
59
|
+
kubectl_path = check_cli_availability("kubectl", "kubectl not found")
|
60
|
+
if not kubectl_path:
|
61
|
+
msg = "kubectl not found"
|
62
|
+
raise FileNotFoundError(msg)
|
63
|
+
return kubectl_path
|
64
|
+
|
65
|
+
|
66
|
+
def ensure_helm_available() -> str:
|
67
|
+
"""Ensure helm CLI is available and return its path."""
|
68
|
+
path = check_cli_availability("helm", "helm not found")
|
69
|
+
if not path:
|
70
|
+
msg = "helm not found"
|
71
|
+
raise FileNotFoundError(msg)
|
72
|
+
return path
|
73
|
+
|
74
|
+
|
75
|
+
def ensure_kind_available() -> str:
|
76
|
+
"""Ensure kind CLI is available and return its path."""
|
77
|
+
path = check_cli_availability("kind", "kind not found")
|
78
|
+
if not path:
|
79
|
+
msg = "kind not found"
|
80
|
+
raise FileNotFoundError(msg)
|
81
|
+
return path
|
82
|
+
|
83
|
+
|
84
|
+
def ensure_core_tools_available() -> None:
|
85
|
+
"""Ensure kubectl, helm, argocd, and kind are available.
|
86
|
+
|
87
|
+
Raises FileNotFoundError if any are missing.
|
88
|
+
"""
|
89
|
+
ensure_kubectl_available()
|
90
|
+
ensure_helm_available()
|
91
|
+
ensure_argocd_available()
|
92
|
+
ensure_kind_available()
|
93
|
+
|
94
|
+
|
95
|
+
def build_kubectl_get_pods_cmd(
|
96
|
+
kubectl_path: str, namespace: str, label_selector: str
|
97
|
+
) -> list[str]:
|
98
|
+
"""Build kubectl command to get pod names with label selector.
|
99
|
+
|
100
|
+
Args:
|
101
|
+
kubectl_path (str): Path to kubectl executable
|
102
|
+
namespace (str): Kubernetes namespace
|
103
|
+
label_selector (str): Label selector for pods
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
list[str]: kubectl command as list of strings
|
107
|
+
"""
|
108
|
+
return [
|
109
|
+
kubectl_path,
|
110
|
+
"get",
|
111
|
+
"pods",
|
112
|
+
"-n",
|
113
|
+
namespace,
|
114
|
+
"-l",
|
115
|
+
label_selector,
|
116
|
+
"-o",
|
117
|
+
"jsonpath={.items[*].metadata.name}",
|
118
|
+
]
|
119
|
+
|
120
|
+
|
121
|
+
def build_kubectl_get_cmd(
|
122
|
+
kubectl_path: str,
|
123
|
+
resource: str,
|
124
|
+
namespace: str,
|
125
|
+
label_selector: str | None = None,
|
126
|
+
output_format: str | None = None,
|
127
|
+
**kwargs: str,
|
128
|
+
) -> list[str]:
|
129
|
+
"""Build a generic kubectl get command.
|
130
|
+
|
131
|
+
Args:
|
132
|
+
kubectl_path (str): Path to kubectl executable
|
133
|
+
resource (str): Kubernetes resource type (e.g., 'pods', 'deployments')
|
134
|
+
namespace (str): Kubernetes namespace
|
135
|
+
label_selector (str | None): Label selector (optional)
|
136
|
+
output_format (str | None): Output format (e.g., 'json', 'yaml', 'custom-columns=...')
|
137
|
+
**kwargs (str): Additional kubectl arguments
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
list[str]: kubectl command as list of strings
|
141
|
+
"""
|
142
|
+
cmd = [kubectl_path, "get", resource, "-n", namespace]
|
143
|
+
|
144
|
+
if label_selector:
|
145
|
+
cmd.extend(["-l", label_selector])
|
146
|
+
|
147
|
+
if output_format:
|
148
|
+
cmd.extend(["-o", output_format])
|
149
|
+
|
150
|
+
# Add any additional arguments
|
151
|
+
for key, value in kwargs.items():
|
152
|
+
cmd.extend([f"--{key}", value])
|
153
|
+
|
154
|
+
return cmd
|
155
|
+
|
156
|
+
|
157
|
+
def build_kubectl_logs_cmd(
|
158
|
+
kubectl_path: str, namespace: str, pod_name: str, tail: int | None = None
|
159
|
+
) -> list[str]:
|
160
|
+
"""Build kubectl command to get logs from a pod.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
kubectl_path (str): Path to kubectl executable
|
164
|
+
namespace (str): Kubernetes namespace
|
165
|
+
pod_name (str): Name of the pod
|
166
|
+
tail (int | None): Number of lines to show from the end (optional)
|
167
|
+
|
168
|
+
Returns:
|
169
|
+
list[str]: kubectl command as list of strings
|
170
|
+
"""
|
171
|
+
cmd = [kubectl_path, "logs", "-n", namespace, pod_name]
|
172
|
+
if tail is not None:
|
173
|
+
cmd.extend(["--tail", str(tail)])
|
174
|
+
return cmd
|
175
|
+
|
176
|
+
|
177
|
+
def run_subprocess(
|
178
|
+
cmd: list[str],
|
179
|
+
*,
|
180
|
+
capture_output: bool = True,
|
181
|
+
text: bool = True,
|
182
|
+
check: bool = True,
|
183
|
+
**kwargs: Any,
|
184
|
+
) -> subprocess.CompletedProcess[str]:
|
185
|
+
"""Run a subprocess command with standardized error handling.
|
186
|
+
|
187
|
+
Args:
|
188
|
+
cmd (list[str]): Command to run as a list of strings
|
189
|
+
capture_output (bool): Whether to capture stdout/stderr
|
190
|
+
text (bool): Whether to decode output as text
|
191
|
+
check (bool): Whether to raise CalledProcessError on non-zero exit
|
192
|
+
**kwargs (Any): Additional arguments to pass to subprocess.run
|
193
|
+
|
194
|
+
Returns:
|
195
|
+
subprocess.CompletedProcess[str]: CompletedProcess instance
|
196
|
+
|
197
|
+
Raises:
|
198
|
+
subprocess.CalledProcessError: If check=True and command fails
|
199
|
+
"""
|
200
|
+
# Extract CLI name from command for validation
|
201
|
+
if cmd:
|
202
|
+
cli_name = cmd[0]
|
203
|
+
# Check if it's a common CLI tool that should be validated
|
204
|
+
if cli_name in ("kubectl", "argocd", "k3s", "kind", "docker"):
|
205
|
+
check_cli_availability(cli_name)
|
206
|
+
|
207
|
+
try:
|
208
|
+
return subprocess.run(
|
209
|
+
cmd,
|
210
|
+
capture_output=capture_output,
|
211
|
+
text=text,
|
212
|
+
check=check,
|
213
|
+
**kwargs,
|
214
|
+
)
|
215
|
+
except subprocess.CalledProcessError as e:
|
216
|
+
logger.debug("Command failed: %s", " ".join(cmd))
|
217
|
+
logger.debug("Return code: %s", e.returncode)
|
218
|
+
if e.stdout:
|
219
|
+
logger.debug("Stdout: %s", e.stdout)
|
220
|
+
if e.stderr:
|
221
|
+
logger.debug("Stderr: %s", e.stderr)
|
222
|
+
raise
|
223
|
+
|
224
|
+
|
225
|
+
def log_subprocess_error(error: subprocess.CalledProcessError) -> None:
|
226
|
+
"""Log a subprocess error in a standardized format.
|
227
|
+
|
228
|
+
Args:
|
229
|
+
error (subprocess.CalledProcessError): The subprocess error to log
|
230
|
+
"""
|
231
|
+
logger.info("❌ Error: %s", error)
|