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,478 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Debugging and troubleshooting tools for ArgoCD.
5
+
6
+ This module provides commands for debugging and troubleshooting ArgoCD applications
7
+ and Kubernetes clusters.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from typing import Any
14
+
15
+ import click
16
+ import yaml
17
+
18
+ from localargo.logging import logger
19
+ from localargo.utils.cli import (
20
+ build_kubectl_get_pods_cmd,
21
+ build_kubectl_logs_cmd,
22
+ check_cli_availability,
23
+ ensure_argocd_available,
24
+ ensure_kubectl_available,
25
+ log_subprocess_error,
26
+ )
27
+
28
+
29
+ @click.group()
30
+ def debug() -> None:
31
+ """Debugging and troubleshooting tools for ArgoCD."""
32
+
33
+
34
+ @debug.command()
35
+ @click.argument("app_name")
36
+ @click.option("--namespace", "-n", default="argocd", help="ArgoCD namespace")
37
+ @click.option("--tail", "-t", type=int, default=50, help="Number of log lines to show")
38
+ def logs(app_name: str, namespace: str, tail: int) -> None:
39
+ """Show ArgoCD application logs."""
40
+ # Check if kubectl is available
41
+ kubectl_path = ensure_kubectl_available()
42
+
43
+ try:
44
+ logger.info("Fetching logs for application '%s'...", app_name)
45
+
46
+ # Get application pods
47
+ label_selector = f"app.kubernetes.io/instance={app_name}"
48
+ cmd = build_kubectl_get_pods_cmd(kubectl_path, namespace, label_selector)
49
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
50
+
51
+ pods = result.stdout.strip().split()
52
+
53
+ if not pods:
54
+ logger.info("āŒ No pods found for application '%s'", app_name)
55
+ return
56
+
57
+ # Show logs for each pod
58
+ for pod in pods:
59
+ logger.info("\nšŸ“„ Logs for pod: %s", pod)
60
+ logger.info("-" * 50)
61
+
62
+ try:
63
+ cmd = build_kubectl_logs_cmd(kubectl_path, namespace, pod, tail)
64
+ subprocess.run(cmd, check=True)
65
+ except subprocess.CalledProcessError as e:
66
+ logger.info("āŒ Error getting logs for pod %s: %s", pod, e)
67
+
68
+ except subprocess.CalledProcessError as e:
69
+ log_subprocess_error(e)
70
+
71
+
72
+ @debug.command()
73
+ @click.argument("app_name")
74
+ @click.option("--check-images", is_flag=True, help="Check if container images exist")
75
+ @click.option("--check-secrets", is_flag=True, help="Check if referenced secrets exist")
76
+ def validate(app_name: str, *, check_images: bool, check_secrets: bool) -> None:
77
+ """Validate ArgoCD application configuration."""
78
+ try:
79
+ argocd_path = ensure_argocd_available()
80
+ kubectl_path = ensure_kubectl_available() if check_secrets else None
81
+
82
+ logger.info("Validating application '%s'...", app_name)
83
+
84
+ app_info = _get_application_info(argocd_path, app_name)
85
+ checks = _perform_basic_validation_checks(app_info)
86
+
87
+ if check_images:
88
+ image_issues = _check_container_images(app_name)
89
+ checks.extend(image_issues)
90
+
91
+ if check_secrets:
92
+ secret_issues = _check_secret_references(app_name, argocd_path, kubectl_path)
93
+ checks.extend(secret_issues)
94
+
95
+ _display_validation_results(checks)
96
+
97
+ except FileNotFoundError:
98
+ logger.error("āŒ argocd CLI not found")
99
+ except subprocess.CalledProcessError as e:
100
+ logger.error("āŒ Error validating application: %s", e)
101
+
102
+
103
+ def _get_application_info(argocd_path: str, app_name: str) -> str:
104
+ """Get application details from ArgoCD."""
105
+ result = subprocess.run(
106
+ [argocd_path, "app", "get", app_name], capture_output=True, text=True, check=True
107
+ )
108
+ return result.stdout
109
+
110
+
111
+ def _perform_basic_validation_checks(app_info: str) -> list[tuple[str, str]]:
112
+ """Perform basic validation checks on application info."""
113
+ checks = []
114
+
115
+ # Check sync status
116
+ if "OutOfSync" in app_info:
117
+ checks.append(("āŒ", "Application is out of sync"))
118
+ else:
119
+ checks.append(("āœ…", "Application sync status OK"))
120
+
121
+ # Check health status
122
+ if "Degraded" in app_info or "Unknown" in app_info:
123
+ checks.append(("āŒ", "Application health is degraded"))
124
+ elif "Healthy" in app_info:
125
+ checks.append(("āœ…", "Application health is OK"))
126
+
127
+ return checks
128
+
129
+
130
+ def _display_validation_results(checks: list[tuple[str, str]]) -> None:
131
+ """Display validation results."""
132
+ logger.info("\nValidation Results:")
133
+ logger.info("=" * 30)
134
+ for status, message in checks:
135
+ logger.info("%s %s", status, message)
136
+
137
+
138
+ def _check_component_health( # pylint: disable=line-too-long
139
+ component: str, description: str, namespace: str, kubectl_path: str
140
+ ) -> tuple[str, str]:
141
+ """Check health of a single ArgoCD component."""
142
+ try:
143
+ result = subprocess.run(
144
+ [
145
+ kubectl_path,
146
+ "get",
147
+ "deployment",
148
+ component,
149
+ "-n",
150
+ namespace,
151
+ "-o",
152
+ "jsonpath={.status.readyReplicas}/{.status.replicas}",
153
+ ],
154
+ capture_output=True,
155
+ text=True,
156
+ check=True,
157
+ )
158
+
159
+ status = result.stdout.strip()
160
+ if "/" in status:
161
+ ready, total = status.split("/")
162
+ if ready == total and ready != "0":
163
+ return ("āœ…", f"{description}: {ready}/{total} ready")
164
+ return ("āŒ", f"{description}: {ready}/{total} ready")
165
+ except subprocess.CalledProcessError:
166
+ return ("āŒ", f"{description}: not found")
167
+
168
+ return ("ā“", f"{description}: status unknown")
169
+
170
+
171
+ def health(namespace: str) -> None:
172
+ """Check ArgoCD system health."""
173
+ kubectl_path = check_cli_availability("kubectl", "kubectl not found")
174
+ if not kubectl_path:
175
+ logger.error("kubectl not found in PATH. Please ensure kubectl is installed.")
176
+ return
177
+
178
+ logger.info("Checking ArgoCD system health...")
179
+
180
+ # Check ArgoCD components
181
+ components = [
182
+ ("argocd-server", "ArgoCD Server"),
183
+ ("argocd-repo-server", "Repository Server"),
184
+ ("argocd-application-controller", "Application Controller"),
185
+ ("argocd-dex-server", "Dex Server (optional)"),
186
+ ("argocd-redis", "Redis Cache"),
187
+ ]
188
+
189
+ health_checks = [
190
+ _check_component_health(comp, desc, namespace, kubectl_path)
191
+ for comp, desc in components
192
+ ]
193
+
194
+ # Display results
195
+ logger.info("\nArgoCD Health Check:")
196
+ logger.info("=" * 30)
197
+ for status, message in health_checks:
198
+ logger.info("%s %s", status, message)
199
+
200
+ # Check API server connectivity
201
+ try:
202
+ argocd_path = ensure_argocd_available()
203
+ except FileNotFoundError:
204
+ logger.error("āŒ argocd CLI not found")
205
+ return
206
+
207
+ try:
208
+ subprocess.run([argocd_path, "version", "--client"], capture_output=True, check=True)
209
+ logger.info("āœ… ArgoCD API connectivity OK")
210
+ except (FileNotFoundError, subprocess.CalledProcessError):
211
+ logger.error("āŒ ArgoCD API connectivity issues")
212
+
213
+
214
+ @debug.command()
215
+ @click.argument("app_name")
216
+ @click.option("--output", "-o", type=click.Path(), help="Output file for events")
217
+ def events(app_name: str, output: str | None) -> None:
218
+ """Show Kubernetes events for an application."""
219
+ # Check if argocd and kubectl CLIs are available
220
+ argocd_path = ensure_argocd_available()
221
+
222
+ kubectl_path = check_cli_availability("kubectl", "kubectl not found")
223
+ if not kubectl_path:
224
+ msg = "kubectl not found"
225
+ raise FileNotFoundError(msg)
226
+
227
+ try:
228
+ # Get application namespace
229
+ result = subprocess.run(
230
+ [
231
+ argocd_path,
232
+ "app",
233
+ "get",
234
+ app_name,
235
+ "-o",
236
+ "jsonpath={.spec.destination.namespace}",
237
+ ],
238
+ capture_output=True,
239
+ text=True,
240
+ check=True,
241
+ )
242
+
243
+ namespace = result.stdout.strip()
244
+
245
+ logger.info(
246
+ "Fetching events for application '%s' in namespace '%s'...", app_name, namespace
247
+ )
248
+
249
+ # Get events
250
+ cmd = [
251
+ kubectl_path,
252
+ "get",
253
+ "events",
254
+ "-n",
255
+ namespace,
256
+ "--sort-by=.metadata.creationTimestamp",
257
+ ]
258
+
259
+ if output:
260
+ # Redirect output to file
261
+ with open(output, "w", encoding="utf-8") as f:
262
+ subprocess.run(cmd, stdout=f, check=True)
263
+ logger.info("āœ… Events written to %s", output)
264
+ else:
265
+ # Show in terminal
266
+ subprocess.run(cmd, check=True)
267
+
268
+ except FileNotFoundError:
269
+ logger.error("āŒ kubectl or argocd CLI not found")
270
+ except subprocess.CalledProcessError as e:
271
+ logger.info(
272
+ "āŒ Error getting events: %s",
273
+ e,
274
+ )
275
+
276
+
277
+ def _check_container_images(app_name: str) -> list[tuple[str, str]]:
278
+ """Check if container images referenced in the app exist."""
279
+ issues: list[tuple[str, str]] = []
280
+
281
+ # Check if argocd CLI is available
282
+ argocd_path = ensure_argocd_available()
283
+
284
+ try:
285
+ manifests = _load_manifests(argocd_path, app_name)
286
+ for manifest in manifests:
287
+ if _is_workload_kind(manifest.get("kind")):
288
+ containers = _get_containers_from_manifest(manifest)
289
+ _collect_image_issues(containers, manifest, issues)
290
+ except (subprocess.CalledProcessError, OSError, ValueError, yaml.YAMLError) as e:
291
+ # Keep this one as it's returning issues, not raising
292
+ issues.append(("āŒ", f"Error checking images: {e}"))
293
+
294
+ return issues
295
+
296
+
297
+ def _load_manifests(argocd_path: str, app_name: str) -> list[dict[str, Any]]:
298
+ """Load manifests for an application via argocd CLI."""
299
+ result = subprocess.run(
300
+ [argocd_path, "app", "manifests", app_name],
301
+ capture_output=True,
302
+ text=True,
303
+ check=True,
304
+ )
305
+ return list(yaml.safe_load_all(result.stdout))
306
+
307
+
308
+ def _is_workload_kind(kind: str | None) -> bool:
309
+ """Return True if kind is a workload that contains containers."""
310
+ return kind in {"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob"}
311
+
312
+
313
+ def _get_containers_from_manifest(manifest: dict[str, Any]) -> list[dict[str, Any]]:
314
+ """Extract containers list from manifest safely."""
315
+ template_spec = _get_template_spec(manifest)
316
+ if template_spec is None:
317
+ return []
318
+ containers = template_spec.get("containers")
319
+ if not isinstance(containers, list):
320
+ return []
321
+ return [c for c in containers if isinstance(c, dict)]
322
+
323
+
324
+ def _get_template_spec(manifest: dict[str, Any]) -> dict[str, Any] | None:
325
+ """Safely navigate to spec.template.spec, returning a dict or None."""
326
+ spec = manifest.get("spec")
327
+ if not isinstance(spec, dict):
328
+ return None
329
+ template = spec.get("template")
330
+ if not isinstance(template, dict):
331
+ return None
332
+ template_spec = template.get("spec")
333
+ if not isinstance(template_spec, dict):
334
+ return None
335
+ return template_spec
336
+
337
+
338
+ def _collect_image_issues(
339
+ containers: list[dict[str, Any]],
340
+ manifest: dict[str, Any],
341
+ issues: list[tuple[str, str]],
342
+ ) -> None:
343
+ """Append image-related issues for given containers to the list."""
344
+ for container in containers:
345
+ image = container.get("image", "")
346
+ if not image:
347
+ name = manifest.get("metadata", {}).get("name", "unknown")
348
+ issues.append(("āŒ", f"Container missing image in {name}"))
349
+ elif ":" not in image:
350
+ issues.append(("āš ļø ", f"Container image without tag: {image}"))
351
+
352
+
353
+ def _check_secret_references(
354
+ app_name: str, argocd_path: str, kubectl_path: str | None
355
+ ) -> list[tuple[str, str]]:
356
+ """Check if secrets referenced in the app exist."""
357
+ issues: list[tuple[str, str]] = []
358
+
359
+ # CLI paths are already validated in the calling function
360
+
361
+ if kubectl_path is None:
362
+ issues.append(("āŒ", "kubectl path is required for secret checking"))
363
+ return issues
364
+
365
+ try:
366
+ app_namespace = _get_app_namespace(argocd_path, app_name)
367
+ secret_refs = _extract_secret_refs_from_manifests(argocd_path, app_name, app_namespace)
368
+ _verify_secrets_exist(kubectl_path, secret_refs, issues)
369
+
370
+ except (subprocess.CalledProcessError, OSError, ValueError, yaml.YAMLError) as exc:
371
+ issues.append(("āŒ", f"Error checking secrets: {exc}"))
372
+
373
+ return issues
374
+
375
+
376
+ def _get_app_namespace(argocd_path: str, app_name: str) -> str:
377
+ """Get the namespace for the ArgoCD app."""
378
+ result = subprocess.run(
379
+ [argocd_path, "app", "get", app_name, "-o", "jsonpath={.spec.destination.namespace}"],
380
+ capture_output=True,
381
+ text=True,
382
+ check=True,
383
+ )
384
+ return result.stdout.strip()
385
+
386
+
387
+ def _extract_secret_refs_from_manifests( # pylint: disable=line-too-long
388
+ argocd_path: str, app_name: str, app_ns: str
389
+ ) -> set[tuple[str, str]]:
390
+ """Extract secret references from app manifests."""
391
+ manifests = _get_app_manifests(argocd_path, app_name)
392
+ secret_refs = set()
393
+
394
+ for manifest in manifests:
395
+ if manifest.get("kind") == "Secret":
396
+ continue
397
+
398
+ container_spec = _get_container_spec(manifest)
399
+ secret_refs.update(_extract_secret_refs_from_containers(container_spec, app_ns))
400
+
401
+ return secret_refs
402
+
403
+
404
+ def _get_app_manifests(argocd_path: str, app_name: str) -> list[dict[str, str]]:
405
+ """Get manifests for an ArgoCD application."""
406
+ result = subprocess.run(
407
+ [argocd_path, "app", "manifests", app_name], capture_output=True, text=True, check=True
408
+ )
409
+ return list(yaml.safe_load_all(result.stdout))
410
+
411
+
412
+ def _get_container_spec(manifest: dict[str, Any]) -> dict[str, Any]:
413
+ """Get the container specification from a manifest."""
414
+ spec = manifest.get("spec", {})
415
+ if not isinstance(spec, dict):
416
+ return {}
417
+ template_spec = spec.get("template", {})
418
+ if not isinstance(template_spec, dict):
419
+ return spec
420
+ container_spec = template_spec.get("spec", spec)
421
+ if not isinstance(container_spec, dict):
422
+ return spec
423
+ return container_spec
424
+
425
+
426
+ def _extract_secret_refs_from_containers(
427
+ container_spec: dict[str, Any], namespace: str
428
+ ) -> set[tuple[str, str]]:
429
+ """Extract secret references from container specifications."""
430
+ secret_refs = set()
431
+
432
+ for container in container_spec.get("containers", []):
433
+ secret_refs.update(_extract_secret_refs_from_env_from(container, namespace))
434
+ secret_refs.update(_extract_secret_refs_from_env_vars(container, namespace))
435
+
436
+ return secret_refs
437
+
438
+
439
+ def _extract_secret_refs_from_env_from(
440
+ container: dict[str, Any], namespace: str
441
+ ) -> set[tuple[str, str]]:
442
+ """Extract secret references from envFrom configurations."""
443
+ secret_refs = set()
444
+
445
+ for env_src in container.get("envFrom", []):
446
+ if "secretRef" in env_src:
447
+ secret_refs.add((env_src["secretRef"]["name"], namespace))
448
+
449
+ return secret_refs
450
+
451
+
452
+ def _extract_secret_refs_from_env_vars(
453
+ container: dict[str, Any], namespace: str
454
+ ) -> set[tuple[str, str]]:
455
+ """Extract secret references from environment variables."""
456
+ secret_refs = set()
457
+
458
+ for env_var in container.get("env", []):
459
+ if "valueFrom" in env_var and "secretKeyRef" in env_var["valueFrom"]:
460
+ secret_refs.add((env_var["valueFrom"]["secretKeyRef"]["name"], namespace))
461
+
462
+ return secret_refs
463
+
464
+
465
+ def _verify_secrets_exist( # pylint: disable=line-too-long
466
+ kubectl_path: str, secret_refs: set[tuple[str, str]], issues: list[tuple[str, str]]
467
+ ) -> None:
468
+ """Verify that referenced secrets exist in the cluster."""
469
+ for secret_name, namespace in secret_refs:
470
+ try:
471
+ subprocess.run(
472
+ [kubectl_path, "get", "secret", secret_name, "-n", namespace],
473
+ capture_output=True,
474
+ check=True,
475
+ )
476
+ issues.append(("āœ…", f"Secret '{secret_name}' exists in '{namespace}'"))
477
+ except subprocess.CalledProcessError:
478
+ issues.append(("āŒ", f"Secret '{secret_name}' not found in '{namespace}'"))