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,291 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Sync ArgoCD applications and local directories.
5
+
6
+ This module provides commands for syncing ArgoCD applications with local directories
7
+ and watching for changes to automatically sync.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import time
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ try:
18
+ from watchdog.events import FileSystemEventHandler
19
+ from watchdog.observers import Observer
20
+ except ImportError:
21
+ FileSystemEventHandler = None
22
+ Observer = None
23
+
24
+ import click
25
+
26
+ from localargo.core.argocd import ArgoClient
27
+ from localargo.logging import logger
28
+ from localargo.utils.cli import run_subprocess
29
+
30
+ if TYPE_CHECKING:
31
+ from watchdog.events import FileSystemEvent
32
+
33
+ # Constants
34
+ SYNC_DEBOUNCE_SECONDS = 2
35
+
36
+
37
+ @click.command()
38
+ @click.option("--watch", "-w", is_flag=True, help="Watch for changes and auto-sync")
39
+ @click.option("--path", "-p", help="Local path to watch (for directory sync)")
40
+ @click.option("--app", "-a", help="Specific ArgoCD application to sync")
41
+ @click.option("--sync-all", is_flag=True, help="Sync all applications")
42
+ @click.option("--force", "-f", is_flag=True, help="Force sync even if no changes")
43
+ def sync_cmd(
44
+ *, watch: bool, path: str | None, app: str | None, sync_all: bool, force: bool
45
+ ) -> None:
46
+ """Sync ArgoCD applications or local directories."""
47
+ if not _validate_sync_arguments(path, app, watch=watch, sync_all=sync_all):
48
+ return
49
+
50
+ sync_mode = _determine_sync_mode(path, app, watch=watch, sync_all=sync_all)
51
+ _execute_sync_mode(sync_mode, path, app, force=force)
52
+
53
+
54
+ def _validate_sync_arguments(
55
+ path: str | None, app: str | None, *, watch: bool, sync_all: bool
56
+ ) -> bool:
57
+ """Validate sync command arguments."""
58
+ if watch and not path and not app:
59
+ logger.error("❌ --watch requires --path or --app")
60
+ return False
61
+
62
+ if sync_all and app:
63
+ logger.error("❌ Cannot specify both --sync-all and --app")
64
+ return False
65
+
66
+ return True
67
+
68
+
69
+ def _determine_sync_mode(
70
+ path: str | None, app: str | None, *, watch: bool, sync_all: bool
71
+ ) -> str:
72
+ """Determine which sync mode to use based on arguments."""
73
+ if watch:
74
+ return "watch"
75
+ if sync_all:
76
+ return "sync_all"
77
+ if app:
78
+ return "sync_app"
79
+ if path:
80
+ return "sync_path"
81
+ return "error"
82
+
83
+
84
+ def _execute_sync_mode(
85
+ sync_mode: str,
86
+ path: str | None,
87
+ app: str | None,
88
+ *,
89
+ force: bool,
90
+ ) -> None:
91
+ """Execute the appropriate sync mode."""
92
+ if sync_mode == "watch":
93
+ _sync_watch(path, app)
94
+ elif sync_mode == "sync_all":
95
+ _sync_all_applications(force=force)
96
+ elif sync_mode == "sync_app":
97
+ _sync_application(app, force=force) # type: ignore[arg-type]
98
+ elif sync_mode == "sync_path":
99
+ _sync_directory(path) # type: ignore[arg-type]
100
+ else:
101
+ logger.error("❌ Specify what to sync: --app, --sync-all, or --path")
102
+
103
+
104
+ def _get_application_list() -> list[str]:
105
+ """Get list of all ArgoCD applications."""
106
+ # Ensure we're authenticated before invoking argocd
107
+ ArgoClient() # constructor auto-logins if needed
108
+ try:
109
+ result = run_subprocess(["argocd", "app", "list", "-o", "name"])
110
+ except subprocess.CalledProcessError as e:
111
+ logger.info(
112
+ "❌ Error listing applications: %s. Try 'localargo cluster password' then re-run.",
113
+ e,
114
+ )
115
+ raise
116
+ names = [app.strip() for app in result.stdout.strip().split("\n") if app.strip()]
117
+ if not names:
118
+ logger.info("i No applications found. Use 'localargo app deploy <app>' to create one.")
119
+ return names
120
+
121
+
122
+ def _sync_multiple_applications(apps: list[str], *, force: bool) -> None:
123
+ """Sync multiple ArgoCD applications."""
124
+ for app in apps:
125
+ _sync_single_application_with_error_handling(app, force=force)
126
+
127
+
128
+ def _sync_single_application_with_error_handling(app_name: str, *, force: bool) -> None:
129
+ """Sync a single application with error handling."""
130
+ try:
131
+ _sync_single_application(app_name, force=force)
132
+ logger.info("✅ '%s' synced", app_name)
133
+ except subprocess.CalledProcessError as e:
134
+ logger.info("❌ Error syncing '%s': %s", app_name, e)
135
+
136
+
137
+ def _sync_single_application(app_name: str, *, force: bool) -> None:
138
+ """Sync a single ArgoCD application."""
139
+ logger.info("Syncing '%s'...", app_name)
140
+ # Ensure authentication prior to syncing
141
+ ArgoClient()
142
+ cmd = ["argocd", "app", "sync", app_name]
143
+ if force:
144
+ cmd.append("--force")
145
+ try:
146
+ subprocess.run(cmd, check=True)
147
+ except subprocess.CalledProcessError as e:
148
+ logger.info(
149
+ "❌ Error syncing '%s': %s. Try re-authenticating (localargo cluster password).",
150
+ app_name,
151
+ e,
152
+ )
153
+ raise
154
+
155
+
156
+ def _sync_application(app_name: str, *, force: bool = False) -> None:
157
+ """Sync a specific ArgoCD application."""
158
+ try:
159
+ logger.info("Syncing application '%s'...", app_name)
160
+ ArgoClient()
161
+ cmd = ["argocd", "app", "sync", app_name]
162
+ if force:
163
+ cmd.append("--force")
164
+
165
+ subprocess.run(cmd, check=True)
166
+ logger.info("✅ Application '%s' synced successfully", app_name)
167
+
168
+ except FileNotFoundError:
169
+ logger.error("❌ argocd CLI not found")
170
+ except subprocess.CalledProcessError as e:
171
+ logger.info("❌ Error syncing application: %s", e)
172
+
173
+
174
+ def _sync_all_applications(*, force: bool = False) -> None:
175
+ """Sync all ArgoCD applications."""
176
+ try:
177
+ logger.info("Syncing all applications...")
178
+ ArgoClient()
179
+ apps = _get_application_list()
180
+ if not apps:
181
+ logger.info("No applications found")
182
+ return
183
+
184
+ logger.info("Found %d applications: %s", len(apps), ", ".join(apps))
185
+
186
+ _sync_multiple_applications(apps, force=force)
187
+ logger.info("✅ All applications sync completed")
188
+
189
+ except FileNotFoundError:
190
+ logger.error("❌ argocd CLI not found")
191
+ except subprocess.CalledProcessError as e:
192
+ logger.info(
193
+ "❌ Error listing applications: %s. Consider re-authenticating via "
194
+ "'localargo cluster password'",
195
+ e,
196
+ )
197
+
198
+
199
+ def _sync_directory(path: str) -> None:
200
+ """Sync a local directory (placeholder for future GitOps integration)."""
201
+ path_obj = Path(path)
202
+
203
+ if not path_obj.exists():
204
+ logger.info("❌ Path does not exist: %s", path)
205
+ return
206
+
207
+ if not path_obj.is_dir():
208
+ logger.info("❌ Path is not a directory: %s", path)
209
+ return
210
+
211
+ logger.info("Directory sync for '%s' not yet implemented", path)
212
+ logger.info("This would integrate with GitOps workflows in the future")
213
+
214
+
215
+ def _sync_watch(path: str | None = None, app: str | None = None) -> None:
216
+ """Watch for changes and auto-sync."""
217
+ if path:
218
+ _watch_directory(path)
219
+ elif app:
220
+ _watch_application(app)
221
+
222
+
223
+ def _watch_directory(path: str) -> None:
224
+ """Watch a directory for changes and sync."""
225
+ if FileSystemEventHandler is None or Observer is None:
226
+ logger.error(
227
+ "❌ watchdog package required for watching. Install with: pip install watchdog"
228
+ )
229
+ return
230
+
231
+ path_obj = Path(path)
232
+ if not path_obj.exists():
233
+ logger.info("❌ Path does not exist: %s", path)
234
+ return
235
+
236
+ class ChangeHandler(FileSystemEventHandler):
237
+ """Handler for file system events during directory watching.
238
+
239
+ Initializes the change handler with last sync timestamp.
240
+ """
241
+
242
+ def __init__(self) -> None:
243
+ self.last_sync = 0.0
244
+
245
+ @property
246
+ def is_ready(self) -> bool:
247
+ """Check if handler is ready for sync operations."""
248
+ return time.time() - self.last_sync > SYNC_DEBOUNCE_SECONDS
249
+
250
+ def on_any_event(self, event: FileSystemEvent) -> None:
251
+ """Handle file system events."""
252
+ # Debounce syncs
253
+ current_time = time.time()
254
+ if current_time - self.last_sync > SYNC_DEBOUNCE_SECONDS:
255
+ logger.info("📁 Change detected: %s", event.src_path)
256
+ # Here you would trigger a sync
257
+ logger.info("🔄 Auto-sync not yet implemented")
258
+ self.last_sync = current_time
259
+
260
+ logger.info("👀 Watching directory: %s", path)
261
+ logger.info("Press Ctrl+C to stop watching")
262
+
263
+ observer = Observer()
264
+ observer.schedule(ChangeHandler(), path, recursive=True)
265
+ observer.start()
266
+
267
+ try:
268
+ observer.join()
269
+ except KeyboardInterrupt:
270
+ observer.stop()
271
+ logger.info("\n✅ Stopped watching")
272
+
273
+
274
+ def _watch_application(app_name: str) -> None:
275
+ """Watch an ArgoCD application for changes."""
276
+ try:
277
+ logger.info("👀 Watching application: %s", app_name)
278
+ logger.info("Press Ctrl+C to stop watching")
279
+
280
+ # Use argocd app wait to watch for changes
281
+ cmd = ["argocd", "app", "wait", app_name, "--watch-only"]
282
+
283
+ try:
284
+ subprocess.run(cmd, check=True)
285
+ except KeyboardInterrupt:
286
+ logger.info("\n✅ Stopped watching application '%s'", app_name)
287
+
288
+ except FileNotFoundError:
289
+ logger.error("❌ argocd CLI not found")
290
+ except subprocess.CalledProcessError as e:
291
+ logger.info("❌ Error watching application: %s", e)
@@ -0,0 +1,288 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Template management for ArgoCD applications.
5
+
6
+ This module provides commands for creating ArgoCD applications from templates.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import click
18
+ import yaml
19
+
20
+ from localargo.logging import logger
21
+ from localargo.utils.cli import check_cli_availability
22
+
23
+
24
+ @dataclass
25
+ class TemplateConfig: # pylint: disable=too-many-instance-attributes
26
+ """Configuration for application template generation."""
27
+
28
+ name: str
29
+ app_type: str
30
+ repo: str
31
+ path: str
32
+ namespace: str
33
+ image: str | None
34
+ port: int
35
+ env_vars: tuple[str, ...]
36
+
37
+
38
+ @click.group()
39
+ def template() -> None:
40
+ """Create ArgoCD applications from templates."""
41
+
42
+
43
+ @template.command()
44
+ @click.argument("name")
45
+ @click.option(
46
+ "--app-type",
47
+ "-t",
48
+ type=click.Choice(["web-app", "api", "worker", "database"]),
49
+ default="web-app",
50
+ help="Application type",
51
+ )
52
+ @click.option("--repo", "-r", help="Git repository URL")
53
+ @click.option("--path", "-p", default=".", help="Path within repository")
54
+ @click.option("--namespace", "-n", default="default", help="Target namespace")
55
+ @click.option("--image", "-i", help="Container image")
56
+ @click.option("--port", type=int, default=80, help="Service port")
57
+ @click.option("--env", multiple=True, help="Environment variables (KEY=VALUE)")
58
+ @click.option("--create-app", is_flag=True, help="Create the ArgoCD application immediately")
59
+ def create( # pylint: disable=too-many-arguments
60
+ name: str,
61
+ app_type: str,
62
+ *,
63
+ repo: str | None,
64
+ path: str,
65
+ namespace: str,
66
+ image: str | None,
67
+ port: int,
68
+ env: tuple[str, ...],
69
+ create_app: bool,
70
+ ) -> None:
71
+ """Create an application from a template."""
72
+ if not repo:
73
+ logger.error("❌ Repository URL required (--repo)")
74
+ return
75
+
76
+ config = _build_template_config(
77
+ name=name,
78
+ app_type=app_type,
79
+ repo=repo,
80
+ path=path,
81
+ namespace=namespace,
82
+ image=image,
83
+ port=port,
84
+ env_vars=env,
85
+ )
86
+ app_config = _generate_app_template(config)
87
+
88
+ _display_generated_config(app_config)
89
+
90
+ if create_app:
91
+ _create_argocd_app(name, app_config)
92
+ else:
93
+ logger.info("\nUse --create-app to create the application immediately")
94
+
95
+
96
+ @template.command()
97
+ def list_templates() -> None:
98
+ """List available application templates."""
99
+ templates = {
100
+ "web-app": "Web application with service and ingress",
101
+ "api": "REST API application",
102
+ "worker": "Background worker/job application",
103
+ "database": "Database deployment (PostgreSQL, MySQL, etc.)",
104
+ }
105
+
106
+ logger.info("Available templates:")
107
+ for name, desc in templates.items():
108
+ logger.info(" %-12s - %s", name, desc)
109
+
110
+
111
+ @template.command()
112
+ @click.argument("template_type")
113
+ def show(template_type: str) -> None:
114
+ """Show template details."""
115
+ if template_type not in ["web-app", "api", "worker", "database"]:
116
+ logger.error("❌ Unknown template type: %s", template_type)
117
+ return
118
+
119
+ # Generate example config
120
+ example_config = _generate_app_template(
121
+ TemplateConfig(
122
+ name=f"example-{template_type}",
123
+ app_type=template_type,
124
+ repo="https://github.com/example/example-repo",
125
+ path=".",
126
+ namespace="default",
127
+ image=f"example/{template_type}:latest",
128
+ port=80,
129
+ env_vars=(),
130
+ )
131
+ )
132
+
133
+ logger.info("Template: %s", template_type)
134
+ logger.info("=" * 30)
135
+ logger.info(yaml.dump(example_config, default_flow_style=False))
136
+
137
+
138
+ def _build_template_config( # pylint: disable=too-many-arguments
139
+ name: str,
140
+ app_type: str,
141
+ *,
142
+ repo: str,
143
+ path: str,
144
+ namespace: str,
145
+ image: str | None,
146
+ port: int,
147
+ env_vars: tuple[str, ...],
148
+ ) -> TemplateConfig:
149
+ """Build a TemplateConfig object from parameters."""
150
+ return TemplateConfig(
151
+ name=name,
152
+ app_type=app_type,
153
+ repo=repo,
154
+ path=path,
155
+ namespace=namespace,
156
+ image=image,
157
+ port=port,
158
+ env_vars=env_vars,
159
+ )
160
+
161
+
162
+ def _display_generated_config(app_config: dict[str, Any]) -> None:
163
+ """Display the generated application configuration."""
164
+ logger.info("Generated ArgoCD Application:")
165
+ logger.info("=" * 50)
166
+ logger.info(yaml.dump(app_config, default_flow_style=False))
167
+
168
+
169
+ def _create_argocd_app(name: str, app_config: dict[str, Any]) -> None:
170
+ """Create an ArgoCD application from the configuration."""
171
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
172
+ yaml.dump(app_config, f)
173
+ temp_file = f.name
174
+
175
+ try:
176
+ argocd_path = check_cli_availability("argocd")
177
+ if argocd_path is None:
178
+ msg = "argocd not found in PATH. Please ensure argocd CLI is installed."
179
+ raise RuntimeError(msg)
180
+ subprocess.run([argocd_path, "app", "create", name, "--file", temp_file], check=True)
181
+ logger.info("✅ ArgoCD application '%s' created", name)
182
+ except FileNotFoundError:
183
+ logger.error("❌ argocd CLI not found")
184
+ except subprocess.CalledProcessError as e:
185
+ logger.info("❌ Error creating application: %s", e)
186
+ finally:
187
+ Path(temp_file).unlink(missing_ok=True)
188
+
189
+
190
+ def _generate_app_template(config: TemplateConfig) -> dict[str, Any]:
191
+ """Generate ArgoCD application configuration from template."""
192
+ app = _create_base_application(config)
193
+ _customize_application_for_type(app, config)
194
+ return app
195
+
196
+
197
+ def _create_base_application(config: TemplateConfig) -> dict[str, Any]:
198
+ """Create the base ArgoCD application structure."""
199
+ return {
200
+ "apiVersion": "argoproj.io/v1alpha1",
201
+ "kind": "Application",
202
+ "metadata": {"name": config.name, "namespace": "argocd"},
203
+ "spec": {
204
+ "project": "default",
205
+ "source": {"repoURL": config.repo, "path": config.path, "targetRevision": "HEAD"},
206
+ "destination": {
207
+ "server": "https://kubernetes.default.svc",
208
+ "namespace": config.namespace,
209
+ },
210
+ "syncPolicy": {"automated": {"prune": True, "selfHeal": True}},
211
+ },
212
+ }
213
+
214
+
215
+ def _customize_application_for_type(app: dict[str, Any], config: TemplateConfig) -> None:
216
+ """Customize the application based on its type."""
217
+ if config.app_type == "web-app":
218
+ _configure_web_app(app, config)
219
+ elif config.app_type == "api":
220
+ _configure_api_app(app, config)
221
+ elif config.app_type == "worker":
222
+ _configure_worker_app(app, config)
223
+ elif config.app_type == "database":
224
+ _configure_database_app(app, config)
225
+
226
+
227
+ def _configure_web_app(app: dict[str, Any], config: TemplateConfig) -> None:
228
+ """Configure a web application."""
229
+ app["spec"]["source"]["helm"] = {
230
+ "parameters": [
231
+ {"name": "image.repository", "value": config.image or f"{config.name}"},
232
+ {"name": "image.tag", "value": "latest"},
233
+ {"name": "service.port", "value": str(config.port)},
234
+ ]
235
+ }
236
+
237
+ if config.env_vars:
238
+ env_params = _build_env_parameters(config.env_vars)
239
+ if "helm" in app["spec"]["source"] and "parameters" in app["spec"]["source"]["helm"]:
240
+ app["spec"]["source"]["helm"]["parameters"].extend(env_params)
241
+
242
+
243
+ def _configure_api_app(app: dict[str, Any], config: TemplateConfig) -> None:
244
+ """Configure an API application."""
245
+ app["spec"]["source"]["helm"] = {
246
+ "parameters": [
247
+ {"name": "image.repository", "value": config.image or f"{config.name}-api"},
248
+ {"name": "image.tag", "value": "latest"},
249
+ {"name": "service.port", "value": str(config.port)},
250
+ {"name": "ingress.enabled", "value": "true"},
251
+ ]
252
+ }
253
+
254
+
255
+ def _configure_worker_app(app: dict[str, Any], config: TemplateConfig) -> None:
256
+ """Configure a worker application."""
257
+ app["spec"]["source"]["helm"] = {
258
+ "parameters": [
259
+ {"name": "image.repository", "value": config.image or f"{config.name}-worker"},
260
+ {"name": "image.tag", "value": "latest"},
261
+ {"name": "replicaCount", "value": "2"},
262
+ ]
263
+ }
264
+ # Remove service-related config for workers
265
+ if "syncPolicy" in app["spec"]:
266
+ app["spec"]["syncPolicy"] = {"automated": {}} # Simpler sync policy
267
+
268
+
269
+ def _configure_database_app(app: dict[str, Any], config: TemplateConfig) -> None:
270
+ """Configure a database application."""
271
+ app["spec"]["source"]["helm"] = {
272
+ "parameters": [
273
+ {"name": "image.repository", "value": config.image or "postgres"},
274
+ {"name": "image.tag", "value": "13"},
275
+ {"name": "persistence.enabled", "value": "true"},
276
+ {"name": "persistence.size", "value": "10Gi"},
277
+ ]
278
+ }
279
+
280
+
281
+ def _build_env_parameters(env_vars: tuple[str, ...]) -> list[dict[str, str]]:
282
+ """Build environment variable parameters for helm."""
283
+ env_params = []
284
+ for env in env_vars:
285
+ if "=" in env:
286
+ key, value = env.split("=", 1)
287
+ env_params.append({"name": f"env.{key}", "value": value})
288
+ return env_params