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/manager.py ADDED
@@ -0,0 +1,232 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Declarative cluster lifecycle management."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import subprocess
10
+ import time
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from localargo.config.manifest import load_manifest
15
+ from localargo.logging import logger
16
+ from localargo.providers.base import ProviderError
17
+ from localargo.providers.registry import get_provider
18
+
19
+ if TYPE_CHECKING:
20
+ from localargo.providers.base import ClusterProvider
21
+
22
+
23
+ class ClusterManagerError(Exception):
24
+ """Base exception for cluster manager errors."""
25
+
26
+
27
+ class ClusterManager:
28
+ """
29
+ Declarative cluster lifecycle manager.
30
+
31
+ Provides unified orchestration for creating, deleting, and managing
32
+ clusters defined in YAML manifests using dynamic provider selection.
33
+
34
+ Args:
35
+ manifest_path (str | Path): Path to cluster manifest file
36
+
37
+ Raises:
38
+ ClusterManagerError: If manifest cannot be loaded
39
+ """
40
+
41
+ def __init__(self, manifest_path: str | Path):
42
+ try:
43
+ self.manifest = load_manifest(manifest_path)
44
+ except Exception as e:
45
+ msg = f"Failed to load manifest: {e}"
46
+ raise ClusterManagerError(msg) from e
47
+
48
+ # Create provider instances for each cluster
49
+ self.providers: dict[str, ClusterProvider] = {}
50
+ for cluster_config in self.manifest.clusters:
51
+ provider_class = get_provider(cluster_config.provider)
52
+ provider_instance = provider_class(name=cluster_config.name)
53
+ self.providers[cluster_config.name] = provider_instance
54
+
55
+ def apply(self) -> dict[str, bool]:
56
+ """
57
+ Create all clusters defined in the manifest.
58
+
59
+ Returns:
60
+ dict[str, bool]: Dictionary mapping cluster names to success status
61
+ """
62
+ logger.info("Applying cluster manifest...")
63
+ results = {}
64
+
65
+ for cluster_config in self.manifest.clusters:
66
+ cluster_name = cluster_config.name
67
+ logger.info(
68
+ "Creating cluster '%s' with provider '%s'",
69
+ cluster_name,
70
+ cluster_config.provider,
71
+ )
72
+
73
+ try:
74
+ provider = self.providers[cluster_name]
75
+ success = provider.create_cluster(**cluster_config.kwargs)
76
+ results[cluster_name] = success
77
+
78
+ if success:
79
+ logger.info("✅ Cluster '%s' created successfully", cluster_name)
80
+ self._update_state_file(cluster_name, "created", cluster_config.provider)
81
+ else:
82
+ logger.error("❌ Failed to create cluster '%s'", cluster_name)
83
+
84
+ except ProviderError as e:
85
+ logger.error("❌ Error creating cluster '%s': %s", cluster_name, e)
86
+ results[cluster_name] = False
87
+ except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
88
+ logger.error("❌ System error creating cluster '%s': %s", cluster_name, e)
89
+ results[cluster_name] = False
90
+
91
+ return results
92
+
93
+ def delete(self) -> dict[str, bool]:
94
+ """
95
+ Delete all clusters defined in the manifest.
96
+
97
+ Returns:
98
+ dict[str, bool]: Dictionary mapping cluster names to success status
99
+ """
100
+ logger.info("Deleting clusters from manifest...")
101
+ results = {}
102
+
103
+ for cluster_config in self.manifest.clusters:
104
+ cluster_name = cluster_config.name
105
+ logger.info("Deleting cluster '%s'", cluster_name)
106
+
107
+ try:
108
+ provider = self.providers[cluster_name]
109
+ success = provider.delete_cluster()
110
+ results[cluster_name] = success
111
+
112
+ if success:
113
+ logger.info("✅ Cluster '%s' deleted successfully", cluster_name)
114
+ self._update_state_file(cluster_name, "deleted", cluster_config.provider)
115
+ else:
116
+ logger.error("❌ Failed to delete cluster '%s'", cluster_name)
117
+
118
+ except ProviderError as e:
119
+ logger.error("❌ Error deleting cluster '%s': %s", cluster_name, e)
120
+ results[cluster_name] = False
121
+ except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
122
+ logger.error("❌ System error deleting cluster '%s': %s", cluster_name, e)
123
+ results[cluster_name] = False
124
+
125
+ return results
126
+
127
+ def status(self) -> dict[str, dict[str, Any]]:
128
+ """
129
+ Get status of all clusters defined in the manifest.
130
+
131
+ Returns:
132
+ dict[str, dict[str, Any]]: Dictionary mapping cluster names to status information
133
+ """
134
+ logger.info("Getting cluster status...")
135
+ results = {}
136
+
137
+ for cluster_config in self.manifest.clusters:
138
+ cluster_name = cluster_config.name
139
+
140
+ try:
141
+ provider = self.providers[cluster_name]
142
+ status_info = provider.get_cluster_status()
143
+ results[cluster_name] = status_info
144
+
145
+ if status_info.get("ready", False):
146
+ logger.info("✅ Cluster '%s': ready", cluster_name)
147
+ elif status_info.get("exists", False):
148
+ logger.warning("⚠️ Cluster '%s': exists but not ready", cluster_name)
149
+ else:
150
+ logger.warning("❌ Cluster '%s': does not exist", cluster_name)
151
+
152
+ except ProviderError as e:
153
+ logger.error("❌ Error getting status for cluster '%s': %s", cluster_name, e)
154
+ results[cluster_name] = {
155
+ "error": str(e),
156
+ "exists": False,
157
+ "ready": False,
158
+ }
159
+ except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
160
+ logger.error(
161
+ "❌ System error getting status for cluster '%s': %s", cluster_name, e
162
+ )
163
+ results[cluster_name] = {
164
+ "error": str(e),
165
+ "exists": False,
166
+ "ready": False,
167
+ }
168
+
169
+ return results
170
+
171
+ def _update_state_file(self, cluster_name: str, action: str, provider: str) -> None:
172
+ """
173
+ Update persistent state file with cluster information.
174
+
175
+ Args:
176
+ cluster_name (str): Name of the cluster
177
+ action (str): Action performed ('created' or 'deleted')
178
+ provider (str): Provider name used
179
+ """
180
+ state_file = Path(".localargo") / "state.json"
181
+ state = _load_state_file(state_file)
182
+ cluster_entry = _find_or_create_cluster_entry(state, cluster_name, provider)
183
+ _update_cluster_entry(cluster_entry, action)
184
+ _save_state_file(state, state_file)
185
+
186
+
187
+ def _load_state_file(state_file: Path) -> dict[str, Any]:
188
+ """Load state from file, handling corruption gracefully."""
189
+ state: dict[str, Any] = {"clusters": []}
190
+ if state_file.exists():
191
+ try:
192
+ with open(state_file, encoding="utf-8") as f:
193
+ state = json.load(f)
194
+ except (json.JSONDecodeError, FileNotFoundError):
195
+ # Reset to empty state if file is corrupted
196
+ state = {"clusters": []}
197
+ return state
198
+
199
+
200
+ def _find_or_create_cluster_entry(
201
+ state: dict[str, Any], cluster_name: str, provider: str
202
+ ) -> Any:
203
+ """Find existing cluster entry or create a new one."""
204
+ for cluster in state["clusters"]:
205
+ if cluster["name"] == cluster_name:
206
+ return cluster
207
+
208
+ # Create new cluster entry
209
+ cluster_entry = {
210
+ "name": cluster_name,
211
+ "provider": provider,
212
+ "created": None,
213
+ "last_action": None,
214
+ }
215
+ state["clusters"].append(cluster_entry)
216
+ return cluster_entry
217
+
218
+
219
+ def _update_cluster_entry(cluster_entry: dict[str, Any], action: str) -> None:
220
+ """Update cluster entry with action and timestamp."""
221
+ timestamp = int(time.time())
222
+ if action == "created":
223
+ cluster_entry["created"] = timestamp
224
+ cluster_entry["last_action"] = action
225
+ cluster_entry["last_updated"] = timestamp
226
+
227
+
228
+ def _save_state_file(state: dict[str, Any], state_file: Path) -> None:
229
+ """Save state to file, ensuring directory exists."""
230
+ state_file.parent.mkdir(exist_ok=True)
231
+ with open(state_file, "w", encoding="utf-8") as f:
232
+ json.dump(state, f, indent=2)
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Cluster provider implementations."""
5
+
6
+ from __future__ import annotations
@@ -0,0 +1,146 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Base classes and interfaces for cluster providers."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import abc
9
+ import subprocess
10
+ import time
11
+ from typing import Any
12
+
13
+ from localargo.utils.cli import check_cli_availability, run_subprocess
14
+
15
+
16
+ def check_process_exited_with_error(
17
+ process: subprocess.Popen, error_msg: str = "Process failed"
18
+ ) -> None:
19
+ """Check if a process has exited and raise error if it failed.
20
+
21
+ Args:
22
+ process (subprocess.Popen): The subprocess.Popen object to check
23
+ error_msg (str): Error message to raise if process exited with non-zero code
24
+
25
+ Raises:
26
+ ClusterCreationError: If process has exited with non-zero return code
27
+ """
28
+ if process.poll() is not None and process.returncode != 0:
29
+ raise ClusterCreationError(error_msg)
30
+
31
+
32
+ class ClusterProvider(abc.ABC):
33
+ """Abstract base class for Kubernetes cluster providers."""
34
+
35
+ def __init__(self, name: str = "localargo"):
36
+ self.name = name
37
+
38
+ @property
39
+ @abc.abstractmethod
40
+ def provider_name(self) -> str:
41
+ """Name of the provider (e.g., 'kind', 'k3s')."""
42
+
43
+ @abc.abstractmethod
44
+ def is_available(self) -> bool:
45
+ """Check if the provider is installed and available."""
46
+
47
+ @abc.abstractmethod
48
+ def create_cluster(self, **kwargs: Any) -> bool:
49
+ """Create a new cluster with the provider."""
50
+
51
+ @abc.abstractmethod
52
+ def delete_cluster(self, name: str | None = None) -> bool:
53
+ """Delete a cluster."""
54
+
55
+ @abc.abstractmethod
56
+ def get_cluster_status(self, name: str | None = None) -> dict[str, Any]:
57
+ """Get cluster status information."""
58
+
59
+ def get_context_name(self, cluster_name: str | None = None) -> str:
60
+ """Get the kubectl context name for this cluster."""
61
+ cluster_name = cluster_name or self.name
62
+ return f"{self.provider_name}-{cluster_name}"
63
+
64
+ def _ensure_kubectl_available(self) -> str:
65
+ """Ensure kubectl is available and return its path.
66
+
67
+ Returns:
68
+ str: Path to kubectl executable
69
+
70
+ Raises:
71
+ FileNotFoundError: If kubectl is not found in PATH
72
+ """
73
+ kubectl_path = check_cli_availability("kubectl", "kubectl not found in PATH")
74
+ if not kubectl_path:
75
+ msg = "kubectl not found in PATH"
76
+ raise FileNotFoundError(msg)
77
+ return kubectl_path
78
+
79
+ def _run_kubectl_command(
80
+ self, cmd: list[str], **kwargs: Any
81
+ ) -> subprocess.CompletedProcess[str]:
82
+ """Run a kubectl command with standardized error handling.
83
+
84
+ Args:
85
+ cmd (list[str]): kubectl command as list of strings
86
+ **kwargs (Any): Additional arguments for subprocess.run
87
+
88
+ Returns:
89
+ subprocess.CompletedProcess[str]: CompletedProcess from subprocess.run
90
+ """
91
+ kubectl_path = self._ensure_kubectl_available()
92
+ return run_subprocess([kubectl_path, *cmd], **kwargs)
93
+
94
+ def _wait_for_cluster_ready(
95
+ self, context_name: str | subprocess.Popen, timeout: int = 300
96
+ ) -> None:
97
+ """Wait for cluster to become ready by checking cluster-info.
98
+
99
+ Args:
100
+ context_name (str | subprocess.Popen): kubectl context name for the cluster
101
+ or process for k3s
102
+ timeout (int): Maximum time to wait in seconds
103
+
104
+ Raises:
105
+ ClusterCreationError: If cluster doesn't become ready within timeout
106
+ """
107
+ start_time = time.time()
108
+ while time.time() - start_time < timeout:
109
+ try:
110
+ if isinstance(context_name, str):
111
+ # Standard kubectl-based wait
112
+ self._run_kubectl_command(
113
+ ["cluster-info", "--context", context_name],
114
+ capture_output=True,
115
+ check=True,
116
+ )
117
+ return
118
+
119
+ # k3s-specific process-based wait
120
+ check_process_exited_with_error(
121
+ context_name, error_msg="k3s server process exited with error"
122
+ )
123
+ except subprocess.CalledProcessError:
124
+ if isinstance(context_name, str):
125
+ time.sleep(2)
126
+ # For k3s, continue polling the process
127
+
128
+ if isinstance(context_name, str):
129
+ msg = f"Cluster '{self.name}' failed to become ready within {timeout} seconds"
130
+ raise ClusterCreationError(msg)
131
+
132
+
133
+ class ProviderError(Exception):
134
+ """Base exception for provider-related errors."""
135
+
136
+
137
+ class ProviderNotAvailableError(ProviderError):
138
+ """Raised when a provider is not available."""
139
+
140
+
141
+ class ClusterCreationError(ProviderError):
142
+ """Raised when cluster creation fails."""
143
+
144
+
145
+ class ClusterOperationError(ProviderError):
146
+ """Raised when a cluster operation fails."""
@@ -0,0 +1,206 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """k3s provider implementation."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from localargo.providers.base import (
16
+ ClusterCreationError,
17
+ ClusterOperationError,
18
+ ClusterProvider,
19
+ ProviderNotAvailableError,
20
+ check_process_exited_with_error,
21
+ )
22
+ from localargo.utils.cli import check_cli_availability, run_subprocess
23
+
24
+
25
+ def build_k3s_server_command(kubeconfig_path: str) -> list[str]:
26
+ """Build the k3s server command with standard options.
27
+
28
+ Args:
29
+ kubeconfig_path (str): Path where kubeconfig should be written
30
+
31
+ Returns:
32
+ list[str]: k3s server command as list of strings
33
+ """
34
+ return [
35
+ "k3s",
36
+ "server",
37
+ "--cluster-init", # Enable clustering
38
+ "--disable",
39
+ "traefik", # Disable default ingress
40
+ "--disable",
41
+ "servicelb", # Disable service load balancer
42
+ "--write-kubeconfig",
43
+ kubeconfig_path,
44
+ ]
45
+
46
+
47
+ class K3sProvider(ClusterProvider):
48
+ """k3s (lightweight Kubernetes) cluster provider."""
49
+
50
+ def __init__(self, name: str = "localargo"):
51
+ super().__init__(name)
52
+ # Use secure temp file for kubeconfig
53
+ _, temp_path = tempfile.mkstemp(suffix=".yaml")
54
+ self._kubeconfig_path = temp_path
55
+
56
+ @property
57
+ def provider_name(self) -> str:
58
+ return "k3s"
59
+
60
+ def is_available(self) -> bool:
61
+ """Check if k3s is installed and available."""
62
+ try:
63
+ k3s_path = check_cli_availability("k3s")
64
+ if not k3s_path:
65
+ return False
66
+ result = subprocess.run(
67
+ [k3s_path, "--version"], capture_output=True, text=True, check=True
68
+ )
69
+ return "k3s" in result.stdout.lower()
70
+ except (subprocess.CalledProcessError, FileNotFoundError, RuntimeError):
71
+ return False
72
+
73
+ def create_cluster(self, **kwargs: Any) -> bool: # noqa: ARG002
74
+ """Create a k3s cluster with ArgoCD-friendly configuration."""
75
+ if not self.is_available():
76
+ msg = "k3s is not installed. Install with: curl -sfL https://get.k3s.io | sh -"
77
+ raise ProviderNotAvailableError(msg)
78
+
79
+ try:
80
+ # Set up environment for k3s
81
+ env = os.environ.copy()
82
+ env["K3S_KUBECONFIG_OUTPUT"] = self._kubeconfig_path
83
+ env["K3S_CLUSTER_NAME"] = self.name
84
+
85
+ # Start k3s server in background
86
+ cmd = build_k3s_server_command(self._kubeconfig_path)
87
+
88
+ # Start k3s server in background - process must persist beyond this method
89
+ process = subprocess.Popen( # pylint: disable=consider-using-with
90
+ cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
91
+ )
92
+
93
+ # Wait for cluster to be ready
94
+ self._wait_for_cluster_ready(process)
95
+
96
+ # Set up kubectl context
97
+ self._configure_kubectl_context()
98
+
99
+ except subprocess.CalledProcessError as e:
100
+ msg = f"Failed to create k3s cluster: {e}"
101
+ raise ClusterCreationError(msg) from e
102
+ except Exception as e:
103
+ msg = f"Unexpected error creating k3s cluster: {e}"
104
+ raise ClusterCreationError(msg) from e
105
+
106
+ return True
107
+
108
+ def delete_cluster(self, name: str | None = None) -> bool:
109
+ """Delete a k3s cluster."""
110
+ # k3s clusters are typically managed as system services
111
+ # For now, we'll provide guidance rather than automated deletion
112
+ cluster_name = name or self.name
113
+ msg = (
114
+ f"k3s cluster '{cluster_name}' deletion must be done manually. "
115
+ "Typically: sudo systemctl stop k3s && sudo k3s-uninstall.sh"
116
+ )
117
+ raise ClusterOperationError(msg)
118
+
119
+ def get_cluster_status(self, name: str | None = None) -> dict[str, Any]:
120
+ """Get k3s cluster status information."""
121
+ cluster_name = name or self.name
122
+
123
+ status = {
124
+ "provider": "k3s",
125
+ "name": cluster_name,
126
+ "exists": False,
127
+ "context": cluster_name,
128
+ "ready": False,
129
+ "kubeconfig": self._kubeconfig_path,
130
+ }
131
+
132
+ # Check if kubeconfig exists and is valid
133
+ kubeconfig = Path(self._kubeconfig_path)
134
+ if kubeconfig.exists():
135
+ status["exists"] = True
136
+
137
+ # Check if cluster is accessible
138
+ try:
139
+ run_subprocess(["kubectl", "--kubeconfig", str(kubeconfig), "cluster-info"])
140
+ status["ready"] = True
141
+ except subprocess.CalledProcessError:
142
+ pass
143
+
144
+ return status
145
+
146
+ def _wait_for_cluster_ready(
147
+ self, context_name: str | subprocess.Popen, timeout: int = 60
148
+ ) -> None:
149
+ """Wait for the k3s cluster to be ready."""
150
+ # k3s implementation expects a process, not a context name
151
+ if isinstance(context_name, str):
152
+ msg = "k3s provider expects a process object, not a context name"
153
+ raise TypeError(msg)
154
+
155
+ start_time = time.time()
156
+
157
+ while time.time() - start_time < timeout:
158
+ check_process_exited_with_error(
159
+ context_name, error_msg="k3s server process exited with error"
160
+ )
161
+ if context_name.poll() is not None:
162
+ # Process has exited successfully
163
+ break
164
+
165
+ # Check if kubeconfig is ready and cluster is accessible
166
+ if self._is_kubeconfig_ready():
167
+ return
168
+
169
+ time.sleep(2)
170
+
171
+ if context_name.poll() is None:
172
+ context_name.terminate()
173
+ msg = f"k3s cluster '{self.name}' failed to become ready within {timeout} seconds"
174
+ raise ClusterCreationError(msg)
175
+
176
+ def _is_kubeconfig_ready(self) -> bool:
177
+ """Return True if kubeconfig exists and cluster is accessible."""
178
+ kubeconfig = Path(self._kubeconfig_path)
179
+ if not kubeconfig.exists():
180
+ return False
181
+ try:
182
+ run_subprocess(["kubectl", "--kubeconfig", str(kubeconfig), "cluster-info"])
183
+ except subprocess.CalledProcessError:
184
+ return False
185
+ return True
186
+
187
+ def _configure_kubectl_context(self) -> None:
188
+ """Configure kubectl context for the k3s cluster."""
189
+ try:
190
+ # Rename the default context to our cluster name
191
+ self._run_kubectl_command(
192
+ [
193
+ "config",
194
+ "--kubeconfig",
195
+ self._kubeconfig_path,
196
+ "rename-context",
197
+ "default",
198
+ self.name,
199
+ ],
200
+ check=True,
201
+ capture_output=True,
202
+ )
203
+
204
+ except subprocess.CalledProcessError as e:
205
+ msg = f"Failed to configure kubectl context: {e}"
206
+ raise ClusterOperationError(msg) from e