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.
Files changed (42) hide show
  1. localargo/__about__.py +6 -0
  2. localargo/__init__.py +6 -0
  3. localargo/__main__.py +11 -0
  4. localargo/cli/__init__.py +49 -0
  5. localargo/cli/commands/__init__.py +5 -0
  6. localargo/cli/commands/app.py +150 -0
  7. localargo/cli/commands/cluster.py +312 -0
  8. localargo/cli/commands/debug.py +478 -0
  9. localargo/cli/commands/port_forward.py +311 -0
  10. localargo/cli/commands/secrets.py +300 -0
  11. localargo/cli/commands/sync.py +291 -0
  12. localargo/cli/commands/template.py +288 -0
  13. localargo/cli/commands/up.py +341 -0
  14. localargo/config/__init__.py +15 -0
  15. localargo/config/manifest.py +520 -0
  16. localargo/config/store.py +66 -0
  17. localargo/core/__init__.py +6 -0
  18. localargo/core/apps.py +330 -0
  19. localargo/core/argocd.py +509 -0
  20. localargo/core/catalog.py +284 -0
  21. localargo/core/cluster.py +149 -0
  22. localargo/core/k8s.py +140 -0
  23. localargo/eyecandy/__init__.py +15 -0
  24. localargo/eyecandy/progress_steps.py +283 -0
  25. localargo/eyecandy/table_renderer.py +154 -0
  26. localargo/eyecandy/tables.py +57 -0
  27. localargo/logging.py +99 -0
  28. localargo/manager.py +232 -0
  29. localargo/providers/__init__.py +6 -0
  30. localargo/providers/base.py +146 -0
  31. localargo/providers/k3s.py +206 -0
  32. localargo/providers/kind.py +326 -0
  33. localargo/providers/registry.py +52 -0
  34. localargo/utils/__init__.py +4 -0
  35. localargo/utils/cli.py +231 -0
  36. localargo/utils/proc.py +148 -0
  37. localargo/utils/retry.py +58 -0
  38. localargo-0.1.0.dist-info/METADATA +149 -0
  39. localargo-0.1.0.dist-info/RECORD +42 -0
  40. localargo-0.1.0.dist-info/WHEEL +4 -0
  41. localargo-0.1.0.dist-info/entry_points.txt +2 -0
  42. 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())
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Utility functions and helpers."""
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)