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
@@ -0,0 +1,284 @@
|
|
1
|
+
"""App catalog schema, loader, and profile overlays for LocalArgo."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import os
|
6
|
+
from dataclasses import dataclass, field
|
7
|
+
from typing import Any, Literal, cast
|
8
|
+
|
9
|
+
import yaml
|
10
|
+
|
11
|
+
|
12
|
+
@dataclass
|
13
|
+
class AppSpec: # pylint: disable=too-many-instance-attributes
|
14
|
+
"""Declarative app specification loaded from localargo.yaml."""
|
15
|
+
|
16
|
+
name: str
|
17
|
+
repo: str
|
18
|
+
path: str = "."
|
19
|
+
type: Literal["kustomize", "helm"] = "kustomize"
|
20
|
+
namespace: str = "default"
|
21
|
+
project: str = "default"
|
22
|
+
sync_policy: Literal["manual", "auto"] = "manual"
|
23
|
+
helm_values: list[str] = field(default_factory=list)
|
24
|
+
health_timeout: int = 300
|
25
|
+
# Optional: if provided, deployment will be done via `kubectl apply -f` on these files
|
26
|
+
# instead of using the ArgoCD CLI to create/update applications.
|
27
|
+
manifest_files: list[str] = field(default_factory=list)
|
28
|
+
|
29
|
+
|
30
|
+
@dataclass
|
31
|
+
class AppState:
|
32
|
+
"""Observed state of an ArgoCD application."""
|
33
|
+
|
34
|
+
name: str
|
35
|
+
namespace: str
|
36
|
+
health: Literal["Healthy", "Progressing", "Degraded", "Unknown"]
|
37
|
+
sync: Literal["Synced", "OutOfSync", "Unknown"]
|
38
|
+
revision: str | None = None
|
39
|
+
|
40
|
+
|
41
|
+
class CatalogError(ValueError):
|
42
|
+
"""Raised on invalid catalog content."""
|
43
|
+
|
44
|
+
|
45
|
+
def load_catalog(path: str = "localargo.yaml", profile: str | None = None) -> list[AppSpec]:
|
46
|
+
"""Load app catalog, applying optional profile overlay if present."""
|
47
|
+
base: dict[str, Any] = _safe_load_yaml(path)
|
48
|
+
specs = _parse_apps(base)
|
49
|
+
if profile:
|
50
|
+
overlay_path = f"localargo.{profile}.yaml"
|
51
|
+
if os.path.exists(overlay_path):
|
52
|
+
overlay = _safe_load_yaml(overlay_path)
|
53
|
+
specs = _merge_overlays(specs, overlay)
|
54
|
+
_validate(specs)
|
55
|
+
return specs
|
56
|
+
|
57
|
+
|
58
|
+
def _safe_load_yaml(path: str) -> dict[str, Any]:
|
59
|
+
if not os.path.exists(path):
|
60
|
+
return {}
|
61
|
+
with open(path, encoding="utf-8") as f:
|
62
|
+
data = yaml.safe_load(f) or {}
|
63
|
+
if not isinstance(data, dict):
|
64
|
+
msg = "Top-level YAML must be a mapping"
|
65
|
+
raise CatalogError(msg)
|
66
|
+
return data
|
67
|
+
|
68
|
+
|
69
|
+
def _parse_apps(data: dict[str, Any]) -> list[AppSpec]:
|
70
|
+
apps_val = data.get("apps", [])
|
71
|
+
if apps_val is None:
|
72
|
+
return []
|
73
|
+
if not isinstance(apps_val, list):
|
74
|
+
msg = "'apps' must be a list"
|
75
|
+
raise CatalogError(msg)
|
76
|
+
specs: list[AppSpec] = []
|
77
|
+
for idx, raw in enumerate(apps_val):
|
78
|
+
specs.append(_build_spec_from_raw(raw, idx))
|
79
|
+
return specs
|
80
|
+
|
81
|
+
|
82
|
+
def _merge_overlays(base_specs: list[AppSpec], overlay: dict[str, Any]) -> list[AppSpec]:
|
83
|
+
overlays = overlay.get("apps", [])
|
84
|
+
if not overlays:
|
85
|
+
return base_specs
|
86
|
+
if not isinstance(overlays, list):
|
87
|
+
msg = "overlay 'apps' must be a list"
|
88
|
+
raise CatalogError(msg)
|
89
|
+
by_name: dict[str, AppSpec] = {s.name: s for s in base_specs}
|
90
|
+
for idx, raw in enumerate(overlays):
|
91
|
+
_apply_overlay_to_map(by_name, raw, idx)
|
92
|
+
return list(by_name.values())
|
93
|
+
|
94
|
+
|
95
|
+
def _build_spec_from_raw(raw: Any, idx: int) -> AppSpec:
|
96
|
+
if not isinstance(raw, dict):
|
97
|
+
msg = f"apps[{idx}] must be a mapping"
|
98
|
+
raise CatalogError(msg)
|
99
|
+
name = _require_str(raw, "name", idx)
|
100
|
+
repo = _require_str(raw, "repo", idx)
|
101
|
+
path = str(raw.get("path", "."))
|
102
|
+
app_type = _parse_app_type(raw, idx)
|
103
|
+
namespace = str(raw.get("namespace", "default"))
|
104
|
+
project = str(raw.get("project", "default"))
|
105
|
+
sync_policy = _parse_sync_policy(raw, idx)
|
106
|
+
helm_values = _parse_helm_values(raw, idx)
|
107
|
+
health_timeout = _parse_health_timeout(raw)
|
108
|
+
manifest_files = _parse_manifest_files(raw)
|
109
|
+
return AppSpec(
|
110
|
+
name=name,
|
111
|
+
repo=repo,
|
112
|
+
path=path,
|
113
|
+
type=app_type,
|
114
|
+
namespace=namespace,
|
115
|
+
project=project,
|
116
|
+
sync_policy=sync_policy,
|
117
|
+
helm_values=[str(v) for v in helm_values],
|
118
|
+
health_timeout=health_timeout,
|
119
|
+
manifest_files=manifest_files,
|
120
|
+
)
|
121
|
+
|
122
|
+
|
123
|
+
def _parse_app_type(raw: dict[str, Any], idx: int) -> Literal["kustomize", "helm"]:
|
124
|
+
app_type = raw.get("type", "kustomize")
|
125
|
+
if app_type not in ("kustomize", "helm"):
|
126
|
+
msg = f"apps[{idx}].type must be 'kustomize' or 'helm'"
|
127
|
+
raise CatalogError(msg)
|
128
|
+
return cast(Literal["kustomize", "helm"], app_type)
|
129
|
+
|
130
|
+
|
131
|
+
def _parse_sync_policy(raw: dict[str, Any], idx: int) -> Literal["manual", "auto"]:
|
132
|
+
policy = raw.get("syncPolicy", "manual")
|
133
|
+
if policy not in ("manual", "auto"):
|
134
|
+
msg = f"apps[{idx}].syncPolicy must be 'manual' or 'auto'"
|
135
|
+
raise CatalogError(msg)
|
136
|
+
return cast(Literal["manual", "auto"], policy)
|
137
|
+
|
138
|
+
|
139
|
+
def _parse_helm_values(raw: dict[str, Any], idx: int) -> list[str]:
|
140
|
+
helm_values = raw.get("helmValues", []) or []
|
141
|
+
if not isinstance(helm_values, list) or not all(
|
142
|
+
isinstance(v, str | os.PathLike) for v in helm_values
|
143
|
+
):
|
144
|
+
msg = f"apps[{idx}].helmValues must be a list of strings"
|
145
|
+
raise CatalogError(msg)
|
146
|
+
return [str(v) for v in helm_values]
|
147
|
+
|
148
|
+
|
149
|
+
def _parse_health_timeout(raw: dict[str, Any]) -> int:
|
150
|
+
return int(raw.get("healthTimeout", 300))
|
151
|
+
|
152
|
+
|
153
|
+
def _normalize_manifest_files(value: Any) -> list[str]:
|
154
|
+
if value is None:
|
155
|
+
return []
|
156
|
+
if isinstance(value, list) and all(isinstance(v, str | os.PathLike) for v in value):
|
157
|
+
return [str(v) for v in value]
|
158
|
+
msg = "manifest files must be a list of strings"
|
159
|
+
raise CatalogError(msg)
|
160
|
+
|
161
|
+
|
162
|
+
def _parse_manifest_files(raw: dict[str, Any]) -> list[str]:
|
163
|
+
# Support both snake_case and camelCase
|
164
|
+
vals = []
|
165
|
+
if "manifest_files" in raw and raw["manifest_files"] is not None:
|
166
|
+
vals.extend(_normalize_manifest_files(raw["manifest_files"]))
|
167
|
+
if "manifestFiles" in raw and raw["manifestFiles"] is not None:
|
168
|
+
vals.extend(_normalize_manifest_files(raw["manifestFiles"]))
|
169
|
+
return vals
|
170
|
+
|
171
|
+
|
172
|
+
def _apply_overlay_to_map(by_name: dict[str, AppSpec], raw: Any, idx: int) -> None:
|
173
|
+
if not isinstance(raw, dict):
|
174
|
+
msg = f"overlay apps[{idx}] must be a mapping"
|
175
|
+
raise CatalogError(msg)
|
176
|
+
name = _require_str(raw, "name", idx)
|
177
|
+
base = by_name.get(name)
|
178
|
+
if not base:
|
179
|
+
merged_list = _parse_apps({"apps": [raw]})
|
180
|
+
if merged_list:
|
181
|
+
by_name[name] = merged_list[0]
|
182
|
+
return
|
183
|
+
_apply_overlay_to_spec(base, raw)
|
184
|
+
|
185
|
+
|
186
|
+
def _apply_overlay_to_spec(base: AppSpec, raw: dict[str, Any]) -> None:
|
187
|
+
_overlay_repo(base, raw)
|
188
|
+
_overlay_path(base, raw)
|
189
|
+
_overlay_type(base, raw)
|
190
|
+
_overlay_namespace(base, raw)
|
191
|
+
_overlay_project(base, raw)
|
192
|
+
_overlay_sync_policy(base, raw)
|
193
|
+
_overlay_helm_values(base, raw)
|
194
|
+
_overlay_health_timeout(base, raw)
|
195
|
+
_overlay_manifest_files(base, raw)
|
196
|
+
|
197
|
+
|
198
|
+
def _overlay_repo(base: AppSpec, raw: dict[str, Any]) -> None:
|
199
|
+
if "repo" in raw:
|
200
|
+
base.repo = str(raw["repo"])
|
201
|
+
|
202
|
+
|
203
|
+
def _overlay_path(base: AppSpec, raw: dict[str, Any]) -> None:
|
204
|
+
if "path" in raw:
|
205
|
+
base.path = str(raw["path"])
|
206
|
+
|
207
|
+
|
208
|
+
def _overlay_type(base: AppSpec, raw: dict[str, Any]) -> None:
|
209
|
+
if "type" in raw:
|
210
|
+
if raw["type"] not in ("kustomize", "helm"):
|
211
|
+
msg = "overlay type must be 'kustomize' or 'helm'"
|
212
|
+
raise CatalogError(msg)
|
213
|
+
base.type = cast(Literal["kustomize", "helm"], raw["type"])
|
214
|
+
|
215
|
+
|
216
|
+
def _overlay_namespace(base: AppSpec, raw: dict[str, Any]) -> None:
|
217
|
+
if "namespace" in raw:
|
218
|
+
base.namespace = str(raw["namespace"])
|
219
|
+
|
220
|
+
|
221
|
+
def _overlay_project(base: AppSpec, raw: dict[str, Any]) -> None:
|
222
|
+
if "project" in raw:
|
223
|
+
base.project = str(raw["project"])
|
224
|
+
|
225
|
+
|
226
|
+
def _overlay_sync_policy(base: AppSpec, raw: dict[str, Any]) -> None:
|
227
|
+
if "syncPolicy" in raw:
|
228
|
+
policy = raw["syncPolicy"]
|
229
|
+
if policy not in ("manual", "auto"):
|
230
|
+
msg = "overlay syncPolicy must be 'manual' or 'auto'"
|
231
|
+
raise CatalogError(msg)
|
232
|
+
base.sync_policy = cast(Literal["manual", "auto"], policy)
|
233
|
+
|
234
|
+
|
235
|
+
def _overlay_helm_values(base: AppSpec, raw: dict[str, Any]) -> None:
|
236
|
+
if "helmValues" in raw:
|
237
|
+
base.helm_values = _normalize_overlay_helm_values(raw["helmValues"])
|
238
|
+
|
239
|
+
|
240
|
+
def _overlay_health_timeout(base: AppSpec, raw: dict[str, Any]) -> None:
|
241
|
+
if "healthTimeout" in raw:
|
242
|
+
base.health_timeout = int(raw["healthTimeout"])
|
243
|
+
|
244
|
+
|
245
|
+
def _overlay_manifest_files(base: AppSpec, raw: dict[str, Any]) -> None:
|
246
|
+
# Merge manifest files if present in overlay
|
247
|
+
files = []
|
248
|
+
if "manifest_files" in raw:
|
249
|
+
files.extend(_normalize_manifest_files(raw["manifest_files"]))
|
250
|
+
if "manifestFiles" in raw:
|
251
|
+
files.extend(_normalize_manifest_files(raw["manifestFiles"]))
|
252
|
+
if files:
|
253
|
+
base.manifest_files = files
|
254
|
+
|
255
|
+
|
256
|
+
def _normalize_overlay_helm_values(value: Any) -> list[str]:
|
257
|
+
hv = value or []
|
258
|
+
if not isinstance(hv, list):
|
259
|
+
msg = "overlay helmValues must be a list of strings"
|
260
|
+
raise CatalogError(msg)
|
261
|
+
for v in hv:
|
262
|
+
if not isinstance(v, str | os.PathLike):
|
263
|
+
msg = "overlay helmValues must be a list of strings"
|
264
|
+
raise CatalogError(msg)
|
265
|
+
return [str(v) for v in hv]
|
266
|
+
|
267
|
+
|
268
|
+
def _validate(specs: list[AppSpec]) -> None:
|
269
|
+
seen: set[str] = set()
|
270
|
+
for s in specs:
|
271
|
+
if not s.name or not s.repo:
|
272
|
+
msg = "Each app requires non-empty 'name' and 'repo'"
|
273
|
+
raise CatalogError(msg)
|
274
|
+
if s.name in seen:
|
275
|
+
msg = f"Duplicate app name: {s.name}"
|
276
|
+
raise CatalogError(msg)
|
277
|
+
seen.add(s.name)
|
278
|
+
|
279
|
+
|
280
|
+
def _require_str(raw: dict[str, Any], key: str, idx: int) -> str:
|
281
|
+
if key not in raw or raw[key] is None or str(raw[key]).strip() == "":
|
282
|
+
msg = f"apps[{idx}].{key} is required"
|
283
|
+
raise CatalogError(msg)
|
284
|
+
return str(raw[key])
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
"""Core cluster management functionality."""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import subprocess
|
9
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
10
|
+
|
11
|
+
from localargo.logging import logger
|
12
|
+
from localargo.providers.k3s import K3sProvider
|
13
|
+
from localargo.providers.kind import KindProvider
|
14
|
+
from localargo.utils.cli import check_cli_availability, run_subprocess
|
15
|
+
|
16
|
+
if TYPE_CHECKING:
|
17
|
+
from localargo.providers.base import ClusterProvider
|
18
|
+
|
19
|
+
|
20
|
+
class ClusterManager:
|
21
|
+
"""High-level cluster management operations."""
|
22
|
+
|
23
|
+
PROVIDERS: ClassVar[dict[str, type[ClusterProvider]]] = {
|
24
|
+
"kind": KindProvider,
|
25
|
+
"k3s": K3sProvider,
|
26
|
+
}
|
27
|
+
|
28
|
+
def __init__(self) -> None:
|
29
|
+
self.providers = {name: cls() for name, cls in self.PROVIDERS.items()}
|
30
|
+
|
31
|
+
def get_available_providers(self) -> list[str]:
|
32
|
+
"""Get list of available (installed) providers."""
|
33
|
+
return [name for name, provider in self.providers.items() if provider.is_available()]
|
34
|
+
|
35
|
+
def get_provider(self, name: str) -> ClusterProvider:
|
36
|
+
"""Get a provider instance by name."""
|
37
|
+
if name not in self.providers:
|
38
|
+
msg = f"Unknown provider: {name}"
|
39
|
+
raise ValueError(msg)
|
40
|
+
return self.providers[name]
|
41
|
+
|
42
|
+
def create_cluster(
|
43
|
+
self, provider_name: str, cluster_name: str = "localargo", **kwargs: Any
|
44
|
+
) -> bool:
|
45
|
+
"""Create a cluster using the specified provider."""
|
46
|
+
provider = self.get_provider(provider_name)
|
47
|
+
provider.name = cluster_name
|
48
|
+
return provider.create_cluster(**kwargs)
|
49
|
+
|
50
|
+
def delete_cluster(self, provider_name: str, cluster_name: str | None = None) -> bool:
|
51
|
+
"""Delete a cluster using the specified provider."""
|
52
|
+
provider = self.get_provider(provider_name)
|
53
|
+
return provider.delete_cluster(cluster_name)
|
54
|
+
|
55
|
+
def get_cluster_status(
|
56
|
+
self, provider_name: str | None = None, cluster_name: str | None = None
|
57
|
+
) -> dict[str, Any]:
|
58
|
+
"""Get cluster status. If provider not specified, check current context."""
|
59
|
+
if provider_name:
|
60
|
+
provider = self.get_provider(provider_name)
|
61
|
+
return provider.get_cluster_status(cluster_name)
|
62
|
+
|
63
|
+
# Try to detect current cluster provider from context
|
64
|
+
try:
|
65
|
+
kubectl_path = check_cli_availability("kubectl")
|
66
|
+
if not kubectl_path:
|
67
|
+
return {}
|
68
|
+
result = run_subprocess(
|
69
|
+
[kubectl_path, "config", "current-context"],
|
70
|
+
)
|
71
|
+
current_context = result.stdout.strip()
|
72
|
+
|
73
|
+
# Try to identify provider from context name
|
74
|
+
for provider in self.providers.values():
|
75
|
+
if current_context.startswith(f"{provider.provider_name}-"):
|
76
|
+
status = provider.get_cluster_status()
|
77
|
+
status["detected_from_context"] = True
|
78
|
+
return status
|
79
|
+
except subprocess.CalledProcessError:
|
80
|
+
return {
|
81
|
+
"provider": "none",
|
82
|
+
"name": "none",
|
83
|
+
"context": "none",
|
84
|
+
"exists": False,
|
85
|
+
"ready": False,
|
86
|
+
"error": "kubectl not available or no current context",
|
87
|
+
}
|
88
|
+
|
89
|
+
# Fallback: generic status - only if try succeeds but no provider found
|
90
|
+
return {
|
91
|
+
"provider": "unknown",
|
92
|
+
"name": "unknown",
|
93
|
+
"context": current_context,
|
94
|
+
"exists": True,
|
95
|
+
"ready": True,
|
96
|
+
"detected_from_context": True,
|
97
|
+
}
|
98
|
+
|
99
|
+
def list_clusters(self) -> list[dict[str, Any]]:
|
100
|
+
"""List all clusters across all providers."""
|
101
|
+
clusters = []
|
102
|
+
|
103
|
+
for provider in self.providers.values():
|
104
|
+
if provider.is_available():
|
105
|
+
try:
|
106
|
+
status = provider.get_cluster_status()
|
107
|
+
clusters.append(status)
|
108
|
+
except (subprocess.SubprocessError, OSError) as e:
|
109
|
+
# Skip clusters we can't get status for
|
110
|
+
logger.warning(
|
111
|
+
"Failed to get status for cluster from %s: %s",
|
112
|
+
provider.provider_name,
|
113
|
+
e,
|
114
|
+
)
|
115
|
+
continue
|
116
|
+
|
117
|
+
return clusters
|
118
|
+
|
119
|
+
def switch_context(self, context_name: str) -> bool:
|
120
|
+
"""Switch to a different kubectl context."""
|
121
|
+
try:
|
122
|
+
kubectl_path = check_cli_availability("kubectl")
|
123
|
+
if not kubectl_path:
|
124
|
+
return False
|
125
|
+
run_subprocess(
|
126
|
+
[kubectl_path, "config", "use-context", context_name], check=True
|
127
|
+
) # Show output for debugging
|
128
|
+
except subprocess.CalledProcessError:
|
129
|
+
return False
|
130
|
+
|
131
|
+
return True
|
132
|
+
|
133
|
+
def get_contexts(self) -> list[str]:
|
134
|
+
"""Get list of available kubectl contexts."""
|
135
|
+
try:
|
136
|
+
kubectl_path = check_cli_availability("kubectl")
|
137
|
+
if not kubectl_path:
|
138
|
+
return []
|
139
|
+
result = run_subprocess(
|
140
|
+
[kubectl_path, "config", "get-contexts", "-o", "name"],
|
141
|
+
)
|
142
|
+
contexts = result.stdout.strip().split("\n")
|
143
|
+
return [ctx for ctx in contexts if ctx] # Filter out empty strings
|
144
|
+
except subprocess.CalledProcessError:
|
145
|
+
return []
|
146
|
+
|
147
|
+
|
148
|
+
# Global cluster manager instance
|
149
|
+
cluster_manager = ClusterManager()
|
localargo/core/k8s.py
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
"""Kubernetes helpers for app pod discovery, log streaming, and manifest apply."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import shutil
|
6
|
+
import subprocess as sp
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import TYPE_CHECKING, Any
|
9
|
+
|
10
|
+
from localargo.logging import logger
|
11
|
+
from localargo.utils.cli import run_subprocess
|
12
|
+
from localargo.utils.proc import run_json, run_stream
|
13
|
+
|
14
|
+
|
15
|
+
def _kubeconfig_args(kubeconfig: str | None) -> list[str]:
|
16
|
+
if not kubeconfig:
|
17
|
+
return []
|
18
|
+
# Allow both file paths and directories (ignore directories gracefully)
|
19
|
+
path = Path(kubeconfig)
|
20
|
+
if path.exists() and path.is_file():
|
21
|
+
return ["--kubeconfig", str(path)]
|
22
|
+
return ["--kubeconfig", str(path)]
|
23
|
+
|
24
|
+
|
25
|
+
def apply_manifests(files: list[str], *, kubeconfig: str | None = None) -> None:
|
26
|
+
"""Apply one or more manifest files using kubectl apply -f.
|
27
|
+
|
28
|
+
Args:
|
29
|
+
files (list[str]): List of file paths (YAML files or directories). Each
|
30
|
+
will be passed to kubectl via repeated -f flags.
|
31
|
+
kubeconfig (str | None): Optional kubeconfig file path. When provided,
|
32
|
+
it's passed to kubectl via --kubeconfig.
|
33
|
+
"""
|
34
|
+
if not files:
|
35
|
+
return
|
36
|
+
args: list[str] = ["kubectl", *(_kubeconfig_args(kubeconfig)), "apply"]
|
37
|
+
for f in files:
|
38
|
+
args.extend(["-f", f])
|
39
|
+
logger.info("Applying manifests: %s", ", ".join(files))
|
40
|
+
run_subprocess(args)
|
41
|
+
|
42
|
+
|
43
|
+
def ensure_namespace(namespace: str) -> None:
|
44
|
+
"""Create namespace if it does not exist."""
|
45
|
+
args = ["kubectl", "get", "ns", namespace, "-o", "name"]
|
46
|
+
result = run_subprocess(args, check=False)
|
47
|
+
if result.returncode != 0:
|
48
|
+
run_subprocess(["kubectl", "create", "ns", namespace])
|
49
|
+
|
50
|
+
|
51
|
+
def upsert_secret(namespace: str, secret_name: str, data: dict[str, str]) -> None:
|
52
|
+
"""Create or update a generic secret with provided key/value pairs.
|
53
|
+
|
54
|
+
Values are passed from environment; empty values are allowed and result in empty strings.
|
55
|
+
"""
|
56
|
+
ensure_namespace(namespace)
|
57
|
+
# Try create
|
58
|
+
base = ["kubectl", "-n", namespace, "create", "secret", "generic", secret_name]
|
59
|
+
for k, v in data.items():
|
60
|
+
base.extend(["--from-literal", f"{k}={v}"])
|
61
|
+
create_result = run_subprocess(base, check=False)
|
62
|
+
if create_result.returncode == 0:
|
63
|
+
return
|
64
|
+
|
65
|
+
# Fallback to kubectl create secret generic --dry-run=client -o yaml | kubectl apply -f -
|
66
|
+
kubectl_path = shutil.which("kubectl")
|
67
|
+
if not kubectl_path:
|
68
|
+
msg = "kubectl not found"
|
69
|
+
raise FileNotFoundError(msg)
|
70
|
+
dry = [
|
71
|
+
kubectl_path,
|
72
|
+
"-n",
|
73
|
+
namespace,
|
74
|
+
"create",
|
75
|
+
"secret",
|
76
|
+
"generic",
|
77
|
+
secret_name,
|
78
|
+
"--dry-run=client",
|
79
|
+
"-o",
|
80
|
+
"yaml",
|
81
|
+
]
|
82
|
+
for k, v in data.items():
|
83
|
+
dry.extend(["--from-literal", f"{k}={v}"])
|
84
|
+
# Pipe into apply using captured output for safer resource handling
|
85
|
+
dry_result = sp.run(dry, check=True, capture_output=True)
|
86
|
+
sp.run([kubectl_path, "apply", "-f", "-"], check=True, input=dry_result.stdout)
|
87
|
+
|
88
|
+
|
89
|
+
if TYPE_CHECKING: # imported only for type checking
|
90
|
+
from collections.abc import Iterator
|
91
|
+
|
92
|
+
|
93
|
+
def list_pods_for_app(app: str, namespace: str) -> list[str]:
|
94
|
+
"""List pods associated with an app using common label conventions."""
|
95
|
+
obj = run_json(["kubectl", "get", "pods", "-n", namespace, "-o", "json"])
|
96
|
+
items = obj.get("items", []) if isinstance(obj, dict) else []
|
97
|
+
pods: list[str] = []
|
98
|
+
for item in items:
|
99
|
+
matched = _extract_pod_name_if_matches(item, app)
|
100
|
+
if matched:
|
101
|
+
pods.append(matched)
|
102
|
+
return pods
|
103
|
+
|
104
|
+
|
105
|
+
def _matches_app(labels: dict[str, Any], app: str) -> bool:
|
106
|
+
values = [
|
107
|
+
labels.get("app.kubernetes.io/instance"),
|
108
|
+
labels.get("app.kubernetes.io/name"),
|
109
|
+
labels.get("app"),
|
110
|
+
labels.get("argo-app"),
|
111
|
+
]
|
112
|
+
return any(isinstance(v, str) and v == app for v in values)
|
113
|
+
|
114
|
+
|
115
|
+
def _extract_pod_name_if_matches(item: Any, app: str) -> str | None:
|
116
|
+
meta = item.get("metadata", {}) if isinstance(item, dict) else {}
|
117
|
+
name = meta.get("name")
|
118
|
+
labels = meta.get("labels", {}) or {}
|
119
|
+
if isinstance(labels, dict) and _matches_app(labels, app) and isinstance(name, str):
|
120
|
+
return name
|
121
|
+
return None
|
122
|
+
|
123
|
+
|
124
|
+
def stream_logs(
|
125
|
+
pod: str,
|
126
|
+
namespace: str,
|
127
|
+
*,
|
128
|
+
container: str | None = None,
|
129
|
+
since: str | None = None,
|
130
|
+
follow: bool = True,
|
131
|
+
) -> Iterator[str]:
|
132
|
+
"""Stream logs from a pod, yielding lines as strings."""
|
133
|
+
args = ["kubectl", "logs", pod, "-n", namespace]
|
134
|
+
if container:
|
135
|
+
args.extend(["-c", container])
|
136
|
+
if since:
|
137
|
+
args.extend(["--since", since])
|
138
|
+
if follow:
|
139
|
+
args.append("-f")
|
140
|
+
return run_stream(args)
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
|
3
|
+
#
|
4
|
+
# SPDX-License-Identifier: MIT
|
5
|
+
|
6
|
+
"""Eyecandy UI abstractions for LocalArgo CLI.
|
7
|
+
|
8
|
+
This module provides Rich-based UI abstractions for the LocalArgo CLI,
|
9
|
+
including table rendering, progress steps, and enhanced CLI styling.
|
10
|
+
"""
|
11
|
+
|
12
|
+
from localargo.eyecandy.progress_steps import StepLogger
|
13
|
+
from localargo.eyecandy.table_renderer import TableRenderer
|
14
|
+
|
15
|
+
__all__ = ["TableRenderer", "StepLogger"]
|