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
localargo/__about__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Version information for localargo."""
5
+
6
+ __version__ = "0.1.0"
localargo/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Localargo - Local Kubernetes cluster management tool."""
5
+
6
+ from __future__ import annotations
localargo/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Main entry point for localargo CLI."""
5
+
6
+ import sys
7
+
8
+ if __name__ == "__main__":
9
+ from localargo.cli import localargo
10
+
11
+ sys.exit(localargo(verbose=False))
@@ -0,0 +1,49 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Command-line interface for LocalArgo."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import rich_click as click
9
+
10
+ from localargo.__about__ import __version__
11
+ from localargo.cli.commands import app, cluster, debug, port_forward, secrets, sync, template
12
+ from localargo.cli.commands import up as up_cmds
13
+ from localargo.logging import init_cli_logging, logger
14
+ from localargo.utils.cli import ensure_core_tools_available
15
+
16
+
17
+ @click.group(
18
+ context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True
19
+ )
20
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging")
21
+ @click.version_option(version=__version__, prog_name="localargo")
22
+ def localargo(*, verbose: bool) -> None:
23
+ """Localargo - Convenient ArgoCD local development tool."""
24
+ # Initialize logging
25
+ init_cli_logging(verbose=verbose)
26
+
27
+ # Check core tools presence on boot
28
+ try:
29
+ ensure_core_tools_available()
30
+ except FileNotFoundError as e:
31
+ logger.info("Required CLI missing: %s", e)
32
+
33
+ ctx = click.get_current_context()
34
+ if ctx is None or ctx.invoked_subcommand is None:
35
+ logger.info("Localargo - Convenient ArgoCD local development tool")
36
+ logger.info("Run 'localargo --help' for available commands.")
37
+
38
+
39
+ # Register subcommands
40
+ localargo.add_command(cluster.cluster)
41
+ localargo.add_command(app.app)
42
+ localargo.add_command(port_forward.port_forward)
43
+ localargo.add_command(secrets.secrets)
44
+ localargo.add_command(sync.sync_cmd)
45
+ localargo.add_command(template.template)
46
+ localargo.add_command(debug.debug)
47
+ localargo.add_command(up_cmds.validate_cmd, name="validate")
48
+ localargo.add_command(up_cmds.up_cmd, name="up")
49
+ localargo.add_command(up_cmds.down_cmd, name="down")
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """CLI commands package."""
@@ -0,0 +1,150 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Application management for ArgoCD.
5
+
6
+ Thin CLI wrapper delegating to core app orchestration.
7
+ """
8
+
9
+ # pylint: disable=too-many-arguments
10
+
11
+ from __future__ import annotations
12
+
13
+ import click
14
+
15
+ from localargo.core import apps as core_apps
16
+
17
+
18
+ @click.group()
19
+ def app() -> None:
20
+ """Manage ArgoCD applications."""
21
+
22
+
23
+ @app.command("list")
24
+ @click.option("--profile", default=None, help="Profile overlay to apply")
25
+ def list_cmd(profile: str | None) -> None:
26
+ """List applications from ArgoCD."""
27
+ core_apps.list_apps(profile=profile)
28
+
29
+
30
+ @app.command()
31
+ @click.argument("name", required=False)
32
+ @click.option("--watch", is_flag=True, help="Watch app status")
33
+ @click.option("--profile", default=None, help="Profile overlay to apply")
34
+ def status(name: str | None, *, watch: bool, profile: str | None) -> None:
35
+ """Show application status (optionally watch)."""
36
+ core_apps.status(name, watch=watch, profile=profile)
37
+
38
+
39
+ @app.command()
40
+ @click.argument("name", required=False)
41
+ @click.option("--all", "all_", is_flag=True, help="Target all apps from catalog")
42
+ @click.option("--wait/--no-wait", default=True, help="Wait for Healthy after sync")
43
+ @click.option("--profile", default=None, help="Profile overlay to apply")
44
+ @click.option(
45
+ "--kubeconfig",
46
+ default=None,
47
+ help="Path to kubeconfig for kubectl-based deployments",
48
+ )
49
+ @click.option(
50
+ "-f",
51
+ "--file",
52
+ "manifest_files",
53
+ multiple=True,
54
+ help="Manifest file or directory to apply (kubectl -f). Repeatable.",
55
+ )
56
+ @click.option("--repo", default=None, help="Git repo URL for ArgoCD app")
57
+ @click.option("--app-name", "app_name_override", default=None, help="ArgoCD application name")
58
+ @click.option("--repo-path", default=".", help="Path within repo (default '.')")
59
+ @click.option("--namespace", default="default", help="Destination namespace")
60
+ @click.option("--project", default="default", help="ArgoCD project")
61
+ @click.option(
62
+ "--type",
63
+ "app_type",
64
+ type=click.Choice(["kustomize", "helm"], case_sensitive=False),
65
+ default="kustomize",
66
+ help="Application type",
67
+ )
68
+ @click.option(
69
+ "--helm-values",
70
+ "helm_values",
71
+ multiple=True,
72
+ help="Additional Helm values files (only for --type helm). Repeatable.",
73
+ )
74
+ def deploy(
75
+ name: str | None,
76
+ *,
77
+ all_: bool,
78
+ wait: bool,
79
+ profile: str | None,
80
+ kubeconfig: str | None,
81
+ manifest_files: tuple[str, ...],
82
+ repo: str | None,
83
+ app_name_override: str | None,
84
+ repo_path: str,
85
+ namespace: str,
86
+ project: str,
87
+ app_type: str,
88
+ helm_values: tuple[str, ...],
89
+ ) -> None:
90
+ """Create/update and sync one or all apps using catalog."""
91
+ core_apps.deploy(
92
+ name,
93
+ all_=all_,
94
+ wait=wait,
95
+ profile=profile,
96
+ kubeconfig=kubeconfig,
97
+ manifest_files=list(manifest_files),
98
+ override_repo=repo,
99
+ override_name=app_name_override,
100
+ override_path=repo_path,
101
+ override_namespace=namespace,
102
+ override_project=project,
103
+ override_type=app_type,
104
+ override_helm_values=list(helm_values),
105
+ )
106
+
107
+
108
+ @app.command()
109
+ @click.argument("name", required=False)
110
+ @click.option("--all", "all_", is_flag=True, help="Target all apps from catalog")
111
+ @click.option("--wait/--no-wait", default=True, help="Wait for Healthy after sync")
112
+ @click.option("--profile", default=None, help="Profile overlay to apply")
113
+ def sync(name: str | None, *, all_: bool, wait: bool, profile: str | None) -> None:
114
+ """Sync one or all apps using catalog."""
115
+ core_apps.sync(name, all_=all_, wait=wait, profile=profile)
116
+
117
+
118
+ @app.command()
119
+ @click.argument("name")
120
+ @click.option("--all-pods", is_flag=True, help="Tail all pods for the app")
121
+ @click.option("--container", default=None, help="Container name to select")
122
+ @click.option("--since", default=None, help="Only return logs newer than a relative time")
123
+ @click.option("--follow/--no-follow", default=True, help="Follow log output")
124
+ @click.option("--profile", default=None, help="Profile overlay to apply")
125
+ def logs(
126
+ name: str,
127
+ *,
128
+ all_pods: bool,
129
+ container: str | None,
130
+ since: str | None,
131
+ follow: bool,
132
+ profile: str | None,
133
+ ) -> None: # pylint: disable=too-many-arguments
134
+ """Tail logs for the selected app."""
135
+ core_apps.logs(
136
+ name,
137
+ all_pods=all_pods,
138
+ container=container,
139
+ since=since,
140
+ follow=follow,
141
+ profile=profile,
142
+ )
143
+
144
+
145
+ @app.command()
146
+ @click.argument("name")
147
+ @click.option("--profile", default=None, help="Profile overlay to apply")
148
+ def delete(name: str, *, profile: str | None) -> None:
149
+ """Delete an application from ArgoCD."""
150
+ core_apps.delete(name, profile=profile)
@@ -0,0 +1,312 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Cluster management commands for ArgoCD development.
5
+
6
+ This module provides commands for managing Kubernetes clusters used for ArgoCD development.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import subprocess
13
+
14
+ import rich_click as click
15
+
16
+ from localargo.core.cluster import cluster_manager
17
+ from localargo.eyecandy.table_renderer import TableRenderer
18
+ from localargo.logging import logger
19
+ from localargo.utils.cli import (
20
+ build_kubectl_get_cmd,
21
+ ensure_kubectl_available,
22
+ )
23
+
24
+
25
+ @click.group()
26
+ def cluster() -> None:
27
+ """Manage Kubernetes clusters for ArgoCD development."""
28
+
29
+
30
+ @cluster.command()
31
+ @click.option("--context", "-c", help="Specific context to use")
32
+ @click.option("--namespace", "-n", default="argocd", help="ArgoCD namespace")
33
+ def status(context: str | None, namespace: str) -> None:
34
+ """Show current cluster and ArgoCD status."""
35
+ kubectl_path = ensure_kubectl_available()
36
+
37
+ try:
38
+ cluster_status = _get_cluster_status(context)
39
+ argocd_installed = _check_argocd_installation(kubectl_path, namespace)
40
+
41
+ _display_cluster_status(cluster_status, namespace, argocd_installed=argocd_installed)
42
+
43
+ if argocd_installed:
44
+ _display_argocd_pods_status(kubectl_path, namespace)
45
+ else:
46
+ _show_argocd_not_found_message(namespace)
47
+
48
+ except subprocess.CalledProcessError as e:
49
+ logger.error("Error checking cluster status: %s", e)
50
+ except FileNotFoundError:
51
+ logger.error("kubectl not found. Please install kubectl.")
52
+
53
+
54
+ def _get_cluster_status(context: str | None) -> dict[str, str | bool]:
55
+ """Get cluster status information."""
56
+ if context:
57
+ logger.info("Using context: %s", context)
58
+ return {"context": context, "ready": True} # Assume ready if specified
59
+
60
+ cluster_status = cluster_manager.get_cluster_status()
61
+ logger.info("Current context: %s", cluster_status.get("context", "unknown"))
62
+ return cluster_status
63
+
64
+
65
+ def _check_argocd_installation(kubectl_path: str, namespace: str) -> bool:
66
+ """Check if ArgoCD is installed in the specified namespace."""
67
+ result = subprocess.run(
68
+ [
69
+ kubectl_path,
70
+ "get",
71
+ "deployment",
72
+ "argocd-server",
73
+ "-n",
74
+ namespace,
75
+ "--ignore-not-found",
76
+ ],
77
+ capture_output=True,
78
+ text=True,
79
+ check=False,
80
+ )
81
+ return bool(result.returncode == 0 and result.stdout.strip())
82
+
83
+
84
+ def _display_cluster_status(
85
+ cluster_status: dict[str, str | bool], namespace: str, *, argocd_installed: bool
86
+ ) -> None:
87
+ """Display the cluster status information."""
88
+ status_data = {
89
+ "Cluster Context": cluster_status.get("context", "unknown"),
90
+ "Cluster Ready": "Yes" if cluster_status.get("ready", False) else "No",
91
+ "ArgoCD Status": "Installed" if argocd_installed else "Not Found",
92
+ "Namespace": namespace,
93
+ }
94
+
95
+ table_renderer = TableRenderer()
96
+ table_renderer.render_key_values("Cluster Status", status_data)
97
+
98
+
99
+ def _display_argocd_pods_status(kubectl_path: str, namespace: str) -> None:
100
+ """Display ArgoCD pods status if available."""
101
+ logger.info("✅ ArgoCD found in namespace: %s", namespace)
102
+
103
+ pod_result = subprocess.run(
104
+ build_kubectl_get_cmd(
105
+ kubectl_path,
106
+ "pods",
107
+ namespace,
108
+ label_selector="app.kubernetes.io/name=argocd-server",
109
+ output_format=(
110
+ "custom-columns=NAME:.metadata.name,"
111
+ "STATUS:.status.phase,"
112
+ "READY:.status.containerStatuses[0].ready"
113
+ ),
114
+ ),
115
+ check=False,
116
+ capture_output=True,
117
+ text=True,
118
+ )
119
+
120
+ if pod_result.returncode == 0 and pod_result.stdout.strip():
121
+ pod_lines = pod_result.stdout.strip().split("\n")[1:] # Skip header
122
+ if pod_lines:
123
+ table_renderer = TableRenderer()
124
+ table_renderer.render_simple_list(
125
+ [line for line in pod_lines if line.strip()], "ArgoCD Pods"
126
+ )
127
+
128
+
129
+ def _show_argocd_not_found_message(namespace: str) -> None:
130
+ """Show message when ArgoCD is not found."""
131
+ logger.warning("❌ ArgoCD not found in namespace: %s", namespace)
132
+ logger.info("Run 'localargo cluster init' to set up ArgoCD locally")
133
+
134
+
135
+ @cluster.command()
136
+ @click.option(
137
+ "--provider",
138
+ type=click.Choice(["kind", "k3s"]),
139
+ default="kind",
140
+ help="Local cluster provider",
141
+ )
142
+ @click.option("--name", default="localargo", help="Cluster name")
143
+ @click.argument("cluster_name", required=False)
144
+ def init(provider: str, name: str, cluster_name: str | None) -> None:
145
+ """Initialize a local Kubernetes cluster with ArgoCD."""
146
+ effective_name = cluster_name or name
147
+ if _do_create_cluster(provider, effective_name):
148
+ _log_kind_hints_if_applicable(provider)
149
+
150
+
151
+ def _do_create_cluster(provider: str, name: str) -> bool:
152
+ """Create cluster with error handling and logging. Returns success flag."""
153
+ logger.info("Initializing %s cluster '%s' with ArgoCD...", provider, name)
154
+
155
+ try:
156
+ success = cluster_manager.create_cluster(provider, name)
157
+ except subprocess.CalledProcessError as e:
158
+ logger.error("❌ Creating cluster failed with return code %s", e.returncode)
159
+ if e.stderr:
160
+ logger.error("Error details: %s", e.stderr.strip())
161
+ return False
162
+ except (OSError, ValueError) as e:
163
+ logger.error("Error creating cluster: %s", e)
164
+ return False
165
+
166
+ if success:
167
+ logger.info("✅ %s cluster '%s' created successfully", provider.upper(), name)
168
+ return True
169
+
170
+ logger.error("Failed to create %s cluster '%s'", provider, name)
171
+ return False
172
+
173
+
174
+ def _log_kind_hints_if_applicable(provider: str) -> None:
175
+ """Log helpful hints when using kind provider."""
176
+ if provider != "kind":
177
+ return
178
+ logger.info(
179
+ "🌐 ArgoCD UI will be available at: "
180
+ "https://argocd.localtest.me (after installation)"
181
+ )
182
+ logger.info("🔧 Development ports available: 30000-30002")
183
+ logger.info("🚀 Cluster configured with direct port access to ingress controller")
184
+
185
+
186
+ @cluster.command()
187
+ @click.argument("context_name")
188
+ def switch(context_name: str) -> None:
189
+ """Switch to a different Kubernetes context."""
190
+ if cluster_manager.switch_context(context_name):
191
+ logger.info("✅ Switched to context: %s", context_name)
192
+ else:
193
+ logger.error("Context '%s' not found", context_name)
194
+
195
+
196
+ @cluster.command()
197
+ def list_contexts() -> None:
198
+ """List available Kubernetes contexts."""
199
+ contexts = cluster_manager.get_contexts()
200
+ if contexts:
201
+ logger.info("Available contexts:")
202
+ for ctx in contexts:
203
+ logger.info(" %s", ctx)
204
+ else:
205
+ logger.error("No contexts found or kubectl not available")
206
+
207
+
208
+ @cluster.command(name="list")
209
+ def list_clusters() -> None:
210
+ """List available clusters across providers."""
211
+ clusters = cluster_manager.list_clusters()
212
+
213
+ rows: list[dict[str, str]] = []
214
+ for c in clusters:
215
+ provider = str(c.get("provider", ""))
216
+ name = str(c.get("name", ""))
217
+ cluster_status = (
218
+ "ready"
219
+ if c.get("ready", False)
220
+ else ("exists" if c.get("exists", False) else "missing")
221
+ )
222
+ context = str(c.get("context", ""))
223
+ rows.append(
224
+ {
225
+ "name": name,
226
+ "provider": provider,
227
+ "status": cluster_status,
228
+ "context": context,
229
+ }
230
+ )
231
+
232
+ renderer = TableRenderer()
233
+ if rows:
234
+ renderer.render_status_table(rows)
235
+ else:
236
+ renderer.render_simple_list([], "Clusters")
237
+
238
+
239
+ @cluster.command()
240
+ @click.argument("name")
241
+ @click.option(
242
+ "--provider",
243
+ type=click.Choice(["kind", "k3s"]),
244
+ default="kind",
245
+ help="Cluster provider",
246
+ )
247
+ def delete(name: str, provider: str) -> None:
248
+ """Delete a specific cluster."""
249
+ logger.info("Deleting %s cluster '%s'...", provider, name)
250
+
251
+ success = cluster_manager.delete_cluster(provider, name)
252
+ if success:
253
+ logger.info("✅ Cluster '%s' deleted successfully", name)
254
+ else:
255
+ error_msg = f"Failed to delete cluster '{name}'"
256
+ logger.error("❌ %s", error_msg)
257
+ raise click.ClickException(error_msg)
258
+
259
+
260
+ @cluster.command()
261
+ @click.argument("name")
262
+ @click.option(
263
+ "--provider",
264
+ type=click.Choice(["kind", "k3s"]),
265
+ default="kind",
266
+ help="Cluster provider",
267
+ )
268
+ def password(name: str, provider: str) -> None:
269
+ """Get ArgoCD initial admin password for a cluster."""
270
+ # Check if kubectl is available
271
+ kubectl_path = ensure_kubectl_available()
272
+
273
+ logger.info("Getting ArgoCD password for %s cluster '%s'...", provider, name)
274
+
275
+ try:
276
+ # Switch to the cluster context if needed
277
+ cluster_manager.switch_context(f"{provider}-{name}")
278
+
279
+ # Get the ArgoCD initial admin secret
280
+ result = subprocess.run(
281
+ [
282
+ kubectl_path,
283
+ "get",
284
+ "secret",
285
+ "argocd-initial-admin-secret",
286
+ "-n",
287
+ "argocd",
288
+ "-o",
289
+ "jsonpath={.data.password}",
290
+ ],
291
+ capture_output=True,
292
+ text=True,
293
+ check=True,
294
+ )
295
+
296
+ if result.stdout:
297
+ # Decode the base64 password
298
+ decoded_password = base64.b64decode(result.stdout.strip()).decode("utf-8")
299
+ logger.info("🔐 ArgoCD Login Credentials:")
300
+ logger.info(" Username: admin")
301
+ logger.info(" Password: %s", decoded_password)
302
+ logger.info(" URL: https://argocd.localtest.me")
303
+ else:
304
+ logger.error("❌ No password found in ArgoCD initial admin secret")
305
+ logger.info("💡 Make sure ArgoCD is installed and the initial admin secret exists")
306
+
307
+ except subprocess.CalledProcessError as e:
308
+ logger.error("❌ Failed to get ArgoCD password: %s", e)
309
+ if "NotFound" in e.stderr:
310
+ logger.info("💡 ArgoCD may not be installed or the cluster may not exist")
311
+ error_msg = "Failed to retrieve ArgoCD password"
312
+ raise click.ClickException(error_msg) from e