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.
- localargo/__about__.py +6 -0
- localargo/__init__.py +6 -0
- localargo/__main__.py +11 -0
- localargo/cli/__init__.py +49 -0
- localargo/cli/commands/__init__.py +5 -0
- localargo/cli/commands/app.py +150 -0
- localargo/cli/commands/cluster.py +312 -0
- localargo/cli/commands/debug.py +478 -0
- localargo/cli/commands/port_forward.py +311 -0
- localargo/cli/commands/secrets.py +300 -0
- localargo/cli/commands/sync.py +291 -0
- localargo/cli/commands/template.py +288 -0
- localargo/cli/commands/up.py +341 -0
- localargo/config/__init__.py +15 -0
- localargo/config/manifest.py +520 -0
- localargo/config/store.py +66 -0
- localargo/core/__init__.py +6 -0
- localargo/core/apps.py +330 -0
- localargo/core/argocd.py +509 -0
- localargo/core/catalog.py +284 -0
- localargo/core/cluster.py +149 -0
- localargo/core/k8s.py +140 -0
- localargo/eyecandy/__init__.py +15 -0
- localargo/eyecandy/progress_steps.py +283 -0
- localargo/eyecandy/table_renderer.py +154 -0
- localargo/eyecandy/tables.py +57 -0
- localargo/logging.py +99 -0
- localargo/manager.py +232 -0
- localargo/providers/__init__.py +6 -0
- localargo/providers/base.py +146 -0
- localargo/providers/k3s.py +206 -0
- localargo/providers/kind.py +326 -0
- localargo/providers/registry.py +52 -0
- localargo/utils/__init__.py +4 -0
- localargo/utils/cli.py +231 -0
- localargo/utils/proc.py +148 -0
- localargo/utils/retry.py +58 -0
- localargo-0.1.0.dist-info/METADATA +149 -0
- localargo-0.1.0.dist-info/RECORD +42 -0
- localargo-0.1.0.dist-info/WHEEL +4 -0
- localargo-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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
|