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,520 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: MIT
|
4
|
+
"""Declarative cluster manifest loader and validator.
|
5
|
+
|
6
|
+
This module supports two related YAML schemas:
|
7
|
+
|
8
|
+
- Legacy cluster-only manifest with top-level key 'clusters' (list of name/provider).
|
9
|
+
- Extended up-manifest used by `localargo up` and `localargo validate` with
|
10
|
+
top-level keys: 'cluster', 'apps', 'repo_creds', 'secrets'.
|
11
|
+
|
12
|
+
Both schemas are parsed into dataclasses with validation helpers.
|
13
|
+
"""
|
14
|
+
|
15
|
+
from __future__ import annotations
|
16
|
+
|
17
|
+
from dataclasses import dataclass, field
|
18
|
+
from pathlib import Path
|
19
|
+
from typing import Any
|
20
|
+
|
21
|
+
from localargo.providers.registry import get_provider
|
22
|
+
|
23
|
+
try: # type: ignore[unused-ignore]
|
24
|
+
import yaml
|
25
|
+
except ImportError: # pragma: no cover - handled at runtime
|
26
|
+
yaml = None # type: ignore[assignment]
|
27
|
+
|
28
|
+
|
29
|
+
class ManifestError(Exception):
|
30
|
+
"""Base exception for manifest-related errors."""
|
31
|
+
|
32
|
+
|
33
|
+
class ManifestValidationError(ManifestError):
|
34
|
+
"""Raised when manifest validation fails."""
|
35
|
+
|
36
|
+
|
37
|
+
# ------------------------
|
38
|
+
# Legacy cluster-only schema
|
39
|
+
# ------------------------
|
40
|
+
|
41
|
+
|
42
|
+
@dataclass
|
43
|
+
class ClusterConfig:
|
44
|
+
"""Configuration for a single cluster."""
|
45
|
+
|
46
|
+
name: str
|
47
|
+
provider: str
|
48
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
49
|
+
|
50
|
+
def __init__(self, name: str, provider: str, **kwargs: Any) -> None:
|
51
|
+
self.name = name
|
52
|
+
self.provider = provider
|
53
|
+
self.kwargs = kwargs
|
54
|
+
|
55
|
+
def __repr__(self) -> str:
|
56
|
+
kwargs_str = f", kwargs={self.kwargs!r}" if self.kwargs else ""
|
57
|
+
return f"ClusterConfig(name={self.name!r}, provider={self.provider!r}{kwargs_str})"
|
58
|
+
|
59
|
+
|
60
|
+
@dataclass
|
61
|
+
class ClusterManifest:
|
62
|
+
"""Cluster manifest containing multiple cluster configurations."""
|
63
|
+
|
64
|
+
clusters: list[ClusterConfig]
|
65
|
+
|
66
|
+
|
67
|
+
def load_manifest(manifest_path: str | Path) -> ClusterManifest:
|
68
|
+
"""
|
69
|
+
Load cluster manifest from YAML file.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
manifest_path (str | Path): Path to YAML manifest file
|
73
|
+
|
74
|
+
Returns:
|
75
|
+
ClusterManifest: Loaded cluster manifest object
|
76
|
+
|
77
|
+
Raises:
|
78
|
+
ManifestError: If manifest cannot be loaded or is invalid
|
79
|
+
"""
|
80
|
+
manifest_path = Path(manifest_path)
|
81
|
+
|
82
|
+
if not manifest_path.exists():
|
83
|
+
msg = f"Manifest file not found: {manifest_path}"
|
84
|
+
raise ManifestError(msg)
|
85
|
+
|
86
|
+
if yaml is None:
|
87
|
+
msg = "PyYAML is required to load manifests. Install with: pip install PyYAML"
|
88
|
+
raise ManifestError(msg)
|
89
|
+
|
90
|
+
try:
|
91
|
+
with open(manifest_path, encoding="utf-8") as f:
|
92
|
+
data = yaml.safe_load(f)
|
93
|
+
except Exception as e:
|
94
|
+
msg = f"Failed to parse manifest file {manifest_path}: {e}"
|
95
|
+
raise ManifestError(msg) from e
|
96
|
+
|
97
|
+
return _parse_manifest_data(data)
|
98
|
+
|
99
|
+
|
100
|
+
def _parse_manifest_data(data: Any) -> ClusterManifest:
|
101
|
+
"""
|
102
|
+
Parse manifest data and validate structure.
|
103
|
+
|
104
|
+
Args:
|
105
|
+
data (Any): Parsed YAML data
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
ClusterManifest: Validated cluster manifest object
|
109
|
+
|
110
|
+
Raises:
|
111
|
+
ManifestValidationError: If data structure is invalid
|
112
|
+
"""
|
113
|
+
if not isinstance(data, dict):
|
114
|
+
msg = f"Manifest must be a dictionary, got {type(data)}"
|
115
|
+
raise ManifestValidationError(msg)
|
116
|
+
|
117
|
+
# Legacy schema: {'clusters': [...]}
|
118
|
+
if "clusters" in data:
|
119
|
+
return _parse_legacy_manifest(data)
|
120
|
+
|
121
|
+
# If not legacy, it might be an up-manifest; validate minimal presence
|
122
|
+
if "cluster" in data and isinstance(data["cluster"], list):
|
123
|
+
clusters = [_parse_cluster_data(c, i) for i, c in enumerate(data["cluster"])]
|
124
|
+
return ClusterManifest(clusters)
|
125
|
+
|
126
|
+
# Preserve legacy error message expected by existing unit tests
|
127
|
+
msg = "Manifest must contain 'clusters' key"
|
128
|
+
raise ManifestValidationError(msg)
|
129
|
+
|
130
|
+
|
131
|
+
def _parse_legacy_manifest(data: dict[str, Any]) -> ClusterManifest:
|
132
|
+
clusters_data = data["clusters"]
|
133
|
+
if not isinstance(clusters_data, list):
|
134
|
+
msg = "Manifest 'clusters' must be a list"
|
135
|
+
raise ManifestValidationError(msg)
|
136
|
+
|
137
|
+
clusters: list[ClusterConfig] = []
|
138
|
+
for i, cluster_data in enumerate(clusters_data):
|
139
|
+
try:
|
140
|
+
cluster = _parse_cluster_data(cluster_data, i)
|
141
|
+
clusters.append(cluster)
|
142
|
+
except ManifestValidationError as e:
|
143
|
+
msg = f"Error in cluster {i}: {e}"
|
144
|
+
raise ManifestValidationError(msg) from e
|
145
|
+
return ClusterManifest(clusters)
|
146
|
+
|
147
|
+
|
148
|
+
def _parse_cluster_data(cluster_data: Any, index: int) -> ClusterConfig:
|
149
|
+
"""
|
150
|
+
Parse individual cluster configuration.
|
151
|
+
|
152
|
+
Args:
|
153
|
+
cluster_data (Any): Cluster configuration data
|
154
|
+
index (int): Cluster index for error reporting
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
ClusterConfig: Parsed cluster configuration object
|
158
|
+
"""
|
159
|
+
_validate_cluster_data_type(cluster_data, index)
|
160
|
+
_validate_required_fields(cluster_data, index)
|
161
|
+
|
162
|
+
name = cluster_data["name"]
|
163
|
+
provider_name = cluster_data["provider"]
|
164
|
+
|
165
|
+
_validate_field_types(name, provider_name, index)
|
166
|
+
_validate_provider_exists(provider_name, index)
|
167
|
+
|
168
|
+
kwargs = _extract_additional_kwargs(cluster_data)
|
169
|
+
|
170
|
+
return ClusterConfig(name=name, provider=provider_name, **kwargs)
|
171
|
+
|
172
|
+
|
173
|
+
def _validate_cluster_data_type(cluster_data: Any, index: int) -> None:
|
174
|
+
"""Validate that cluster data is a dictionary."""
|
175
|
+
if not isinstance(cluster_data, dict):
|
176
|
+
msg = f"Cluster {index} must be a dictionary"
|
177
|
+
raise ManifestValidationError(msg)
|
178
|
+
|
179
|
+
|
180
|
+
def _validate_required_fields(cluster_data: dict[str, Any], index: int) -> None:
|
181
|
+
"""Validate that required fields are present."""
|
182
|
+
if "name" not in cluster_data:
|
183
|
+
msg = f"Cluster {index} missing required 'name' field"
|
184
|
+
raise ManifestValidationError(msg)
|
185
|
+
|
186
|
+
if "provider" not in cluster_data:
|
187
|
+
msg = f"Cluster {index} missing required 'provider' field"
|
188
|
+
raise ManifestValidationError(msg)
|
189
|
+
|
190
|
+
|
191
|
+
def _validate_field_types(name: Any, provider_name: Any, index: int) -> None:
|
192
|
+
"""Validate that name and provider fields are strings."""
|
193
|
+
if not isinstance(name, str):
|
194
|
+
msg = f"Cluster {index} 'name' must be a string"
|
195
|
+
raise ManifestValidationError(msg)
|
196
|
+
|
197
|
+
if not isinstance(provider_name, str):
|
198
|
+
msg = f"Cluster {index} 'provider' must be a string"
|
199
|
+
raise ManifestValidationError(msg)
|
200
|
+
|
201
|
+
|
202
|
+
def _validate_provider_exists(provider_name: str, index: int) -> None:
|
203
|
+
"""Validate that the provider exists."""
|
204
|
+
try:
|
205
|
+
get_provider(provider_name)
|
206
|
+
except ValueError as e:
|
207
|
+
msg = f"Cluster {index}: {e}"
|
208
|
+
raise ManifestValidationError(msg) from e
|
209
|
+
|
210
|
+
|
211
|
+
def _extract_additional_kwargs(cluster_data: dict[str, Any]) -> dict[str, Any]:
|
212
|
+
"""Extract additional kwargs excluding name and provider."""
|
213
|
+
return {k: v for k, v in cluster_data.items() if k not in ("name", "provider")}
|
214
|
+
|
215
|
+
|
216
|
+
def validate_manifest(manifest_path: str | Path) -> bool:
|
217
|
+
"""
|
218
|
+
Validate manifest file without loading it.
|
219
|
+
|
220
|
+
Args:
|
221
|
+
manifest_path (str | Path): Path to manifest file
|
222
|
+
|
223
|
+
Returns:
|
224
|
+
bool: True if manifest is valid
|
225
|
+
"""
|
226
|
+
load_manifest(manifest_path)
|
227
|
+
return True
|
228
|
+
|
229
|
+
|
230
|
+
# ------------------------
|
231
|
+
# Extended up-manifest schema (cluster/apps/repo_creds/secrets)
|
232
|
+
# ------------------------
|
233
|
+
|
234
|
+
|
235
|
+
@dataclass
|
236
|
+
class AppHelmConfig:
|
237
|
+
"""Helm-specific options for an application entry."""
|
238
|
+
|
239
|
+
release_name: str | None = None
|
240
|
+
value_files: list[str] = field(default_factory=list)
|
241
|
+
|
242
|
+
|
243
|
+
@dataclass
|
244
|
+
class SourceSpec:
|
245
|
+
"""A single application source entry (git path or helm chart)."""
|
246
|
+
|
247
|
+
repo_url: str
|
248
|
+
target_revision: str = "HEAD"
|
249
|
+
path: str | None = None
|
250
|
+
chart: str | None = None
|
251
|
+
ref: str | None = None
|
252
|
+
helm: AppHelmConfig | None = None
|
253
|
+
|
254
|
+
|
255
|
+
@dataclass
|
256
|
+
class AppEntry: # pylint: disable=too-many-instance-attributes
|
257
|
+
"""Application entry as defined in up-manifest 'apps' list."""
|
258
|
+
|
259
|
+
name: str
|
260
|
+
namespace: str
|
261
|
+
app_file: str | None = None
|
262
|
+
sources: list[SourceSpec] = field(default_factory=list)
|
263
|
+
# Back-compat normalized single-source view for current CLI code paths
|
264
|
+
repo_url: str = ""
|
265
|
+
target_revision: str = "HEAD"
|
266
|
+
path: str = "."
|
267
|
+
helm: AppHelmConfig | None = None
|
268
|
+
chart_name: str | None = None
|
269
|
+
# reduce pylint instance-attribute warning by grouping extra computed fields
|
270
|
+
_extras: dict[str, Any] = field(default_factory=dict, repr=False)
|
271
|
+
|
272
|
+
|
273
|
+
@dataclass
|
274
|
+
class RepoCredEntry:
|
275
|
+
"""Repository credential entry for ArgoCD access."""
|
276
|
+
|
277
|
+
name: str
|
278
|
+
repo_url: str
|
279
|
+
username: str
|
280
|
+
password: str
|
281
|
+
type: str = "git"
|
282
|
+
enable_oci: bool = False
|
283
|
+
description: str | None = None
|
284
|
+
|
285
|
+
|
286
|
+
@dataclass
|
287
|
+
class SecretValueFromEnv:
|
288
|
+
"""Secret value sourced from an environment variable."""
|
289
|
+
|
290
|
+
from_env: str
|
291
|
+
|
292
|
+
|
293
|
+
@dataclass
|
294
|
+
class SecretEntry:
|
295
|
+
"""Kubernetes secret specification to be created or updated."""
|
296
|
+
|
297
|
+
name: str
|
298
|
+
namespace: str
|
299
|
+
secret_name: str
|
300
|
+
secret_key: str
|
301
|
+
secret_value: list[SecretValueFromEnv]
|
302
|
+
|
303
|
+
|
304
|
+
@dataclass
|
305
|
+
class UpManifest:
|
306
|
+
"""Top-level up-manifest schema used by validate/up/down commands."""
|
307
|
+
|
308
|
+
clusters: list[ClusterConfig]
|
309
|
+
apps: list[AppEntry]
|
310
|
+
repo_creds: list[RepoCredEntry]
|
311
|
+
secrets: list[SecretEntry]
|
312
|
+
|
313
|
+
|
314
|
+
def load_up_manifest(path: str | Path) -> UpManifest:
|
315
|
+
"""Load extended up-manifest matching provided YAML example."""
|
316
|
+
p = Path(path)
|
317
|
+
_ensure_manifest_file(p)
|
318
|
+
raw = _load_yaml_mapping(p)
|
319
|
+
clusters = _parse_clusters(raw.get("cluster") or [])
|
320
|
+
apps = _parse_apps(raw.get("apps") or [], base_dir=p.parent)
|
321
|
+
repocreds = _parse_repo_creds(raw.get("repo_creds") or [])
|
322
|
+
secrets = _parse_secrets(raw.get("secrets") or [])
|
323
|
+
return UpManifest(clusters=clusters, apps=apps, repo_creds=repocreds, secrets=secrets)
|
324
|
+
|
325
|
+
|
326
|
+
def _ensure_manifest_file(path: Path) -> None:
|
327
|
+
if not path.exists():
|
328
|
+
msg = f"Manifest file not found: {path}"
|
329
|
+
raise ManifestError(msg)
|
330
|
+
if yaml is None:
|
331
|
+
msg = "PyYAML is required to load manifests. Install with: pip install PyYAML"
|
332
|
+
raise ManifestError(msg)
|
333
|
+
|
334
|
+
|
335
|
+
def _load_yaml_mapping(path: Path) -> dict[str, Any]:
|
336
|
+
with open(path, encoding="utf-8") as f:
|
337
|
+
data = yaml.safe_load(f) or {}
|
338
|
+
if not isinstance(data, dict):
|
339
|
+
msg = "Up-manifest must be a mapping"
|
340
|
+
raise ManifestValidationError(msg)
|
341
|
+
return data
|
342
|
+
|
343
|
+
|
344
|
+
def _parse_clusters(clusters_raw: Any) -> list[ClusterConfig]:
|
345
|
+
if not isinstance(clusters_raw, list) or not clusters_raw:
|
346
|
+
msg = "'cluster' must be a non-empty list"
|
347
|
+
raise ManifestValidationError(msg)
|
348
|
+
return [_parse_cluster_data(c, i) for i, c in enumerate(clusters_raw)]
|
349
|
+
|
350
|
+
|
351
|
+
def _parse_apps(apps_raw: Any, *, base_dir: Path) -> list[AppEntry]:
|
352
|
+
if not isinstance(apps_raw, list):
|
353
|
+
msg = "'apps' must be a list"
|
354
|
+
raise ManifestValidationError(msg)
|
355
|
+
result: list[AppEntry] = []
|
356
|
+
for idx, item in enumerate(apps_raw):
|
357
|
+
result.append(_parse_single_app(idx, item, base_dir=base_dir))
|
358
|
+
return result
|
359
|
+
|
360
|
+
|
361
|
+
def _parse_single_app(idx: int, item: Any, *, base_dir: Path) -> AppEntry:
|
362
|
+
name, spec_any = _coerce_single_key_mapping(item, f"apps[{idx}]")
|
363
|
+
namespace = str(spec_any.get("namespace", "")).strip()
|
364
|
+
if not namespace:
|
365
|
+
msg = f"apps[{idx}].{name} missing required 'namespace' field"
|
366
|
+
raise ManifestValidationError(msg)
|
367
|
+
app_file_raw = spec_any.get("app_file")
|
368
|
+
app_file: str | None = None
|
369
|
+
if isinstance(app_file_raw, str) and app_file_raw.strip():
|
370
|
+
# Resolve relative to manifest directory
|
371
|
+
app_path = (base_dir / app_file_raw).resolve()
|
372
|
+
app_file = str(app_path)
|
373
|
+
sources = _parse_sources(idx, name, spec_any.get("sources"))
|
374
|
+
if sources:
|
375
|
+
return _normalize_first_source(name, namespace, sources, app_file)
|
376
|
+
return _parse_single_source_fallback(name, namespace, spec_any, app_file)
|
377
|
+
|
378
|
+
|
379
|
+
def _parse_sources(idx: int, app_name: str, raw: Any) -> list[SourceSpec]:
|
380
|
+
if not isinstance(raw, list) or not raw:
|
381
|
+
return []
|
382
|
+
return [_build_source_spec(idx, app_name, sidx, s) for sidx, s in enumerate(raw)]
|
383
|
+
|
384
|
+
|
385
|
+
def _build_source_spec(idx: int, app_name: str, sidx: int, s: Any) -> SourceSpec:
|
386
|
+
if not isinstance(s, dict):
|
387
|
+
msg = f"apps[{idx}].{app_name} sources[{sidx}] must be a mapping"
|
388
|
+
raise ManifestValidationError(msg)
|
389
|
+
repo_url = str(s.get("repoURL", ""))
|
390
|
+
target_revision = str(s.get("targetRevision", "HEAD"))
|
391
|
+
path_raw = s.get("path")
|
392
|
+
chart_raw = s.get("chart")
|
393
|
+
ref_raw = s.get("ref")
|
394
|
+
path_val = None if path_raw is None else str(path_raw)
|
395
|
+
chart_val = None if chart_raw is None else str(chart_raw)
|
396
|
+
ref_val = None if ref_raw is None else str(ref_raw)
|
397
|
+
helm_cfg = _parse_helm_config(s.get("helm"))
|
398
|
+
return SourceSpec(
|
399
|
+
repo_url=repo_url,
|
400
|
+
target_revision=target_revision,
|
401
|
+
path=path_val,
|
402
|
+
chart=chart_val,
|
403
|
+
ref=ref_val,
|
404
|
+
helm=helm_cfg,
|
405
|
+
)
|
406
|
+
|
407
|
+
|
408
|
+
def _normalize_first_source(
|
409
|
+
name: str, namespace: str, sources: list[SourceSpec], app_file: str | None
|
410
|
+
) -> AppEntry:
|
411
|
+
first = sources[0]
|
412
|
+
return AppEntry(
|
413
|
+
name=name,
|
414
|
+
namespace=namespace,
|
415
|
+
app_file=app_file,
|
416
|
+
sources=sources,
|
417
|
+
repo_url=first.repo_url,
|
418
|
+
target_revision=first.target_revision,
|
419
|
+
path=first.path or ".",
|
420
|
+
helm=first.helm,
|
421
|
+
chart_name=first.chart,
|
422
|
+
)
|
423
|
+
|
424
|
+
|
425
|
+
def _parse_single_source_fallback(
|
426
|
+
name: str, namespace: str, spec_any: dict[str, Any], app_file: str | None
|
427
|
+
) -> AppEntry:
|
428
|
+
repo_url = str(spec_any.get("repoURL", ""))
|
429
|
+
path_val = str(spec_any.get("path", "."))
|
430
|
+
target_revision = str(spec_any.get("targetRevision", "HEAD"))
|
431
|
+
helm_cfg = _parse_helm_config(spec_any.get("helm"))
|
432
|
+
return AppEntry(
|
433
|
+
name=name,
|
434
|
+
namespace=namespace,
|
435
|
+
app_file=app_file,
|
436
|
+
sources=[],
|
437
|
+
repo_url=repo_url,
|
438
|
+
target_revision=target_revision,
|
439
|
+
path=path_val,
|
440
|
+
helm=helm_cfg,
|
441
|
+
chart_name=None,
|
442
|
+
)
|
443
|
+
|
444
|
+
|
445
|
+
def _parse_repo_creds(repocreds_raw: Any) -> list[RepoCredEntry]:
|
446
|
+
if not isinstance(repocreds_raw, list):
|
447
|
+
msg = "'repo_creds' must be a list"
|
448
|
+
raise ManifestValidationError(msg)
|
449
|
+
result: list[RepoCredEntry] = []
|
450
|
+
for idx, item in enumerate(repocreds_raw):
|
451
|
+
result.append(_parse_single_repo_cred(idx, item))
|
452
|
+
return result
|
453
|
+
|
454
|
+
|
455
|
+
def _parse_single_repo_cred(idx: int, item: Any) -> RepoCredEntry:
|
456
|
+
name, spec_any = _coerce_single_key_mapping(item, f"repo_creds[{idx}]")
|
457
|
+
return RepoCredEntry(
|
458
|
+
name=name,
|
459
|
+
repo_url=str(spec_any.get("repoURL", "")),
|
460
|
+
username=str(spec_any.get("username", "")),
|
461
|
+
password=str(spec_any.get("password", "")),
|
462
|
+
type=str(spec_any.get("type", "git")),
|
463
|
+
enable_oci=bool(spec_any.get("enableOCI", False)),
|
464
|
+
description=(
|
465
|
+
str(spec_any.get("description"))
|
466
|
+
if spec_any.get("description") is not None
|
467
|
+
else None
|
468
|
+
),
|
469
|
+
)
|
470
|
+
|
471
|
+
|
472
|
+
def _parse_secrets(secrets_raw: Any) -> list[SecretEntry]:
|
473
|
+
if not isinstance(secrets_raw, list):
|
474
|
+
msg = "'secrets' must be a list"
|
475
|
+
raise ManifestValidationError(msg)
|
476
|
+
result: list[SecretEntry] = []
|
477
|
+
for idx, item in enumerate(secrets_raw):
|
478
|
+
result.append(_parse_single_secret(idx, item))
|
479
|
+
return result
|
480
|
+
|
481
|
+
|
482
|
+
def _parse_single_secret(idx: int, item: Any) -> SecretEntry:
|
483
|
+
name, spec_any = _coerce_single_key_mapping(item, f"secrets[{idx}]")
|
484
|
+
vals = _parse_secret_values(spec_any.get("secretValue") or [])
|
485
|
+
return SecretEntry(
|
486
|
+
name=name,
|
487
|
+
namespace=str(spec_any.get("namespace", "default")),
|
488
|
+
secret_name=str(spec_any.get("secretName", "")),
|
489
|
+
secret_key=str(spec_any.get("secretKey", "")),
|
490
|
+
secret_value=vals,
|
491
|
+
)
|
492
|
+
|
493
|
+
|
494
|
+
def _parse_helm_config(helm_raw: Any) -> AppHelmConfig | None:
|
495
|
+
if not isinstance(helm_raw, dict):
|
496
|
+
return None
|
497
|
+
release = str(helm_raw.get("releaseName")) if helm_raw.get("releaseName") else None
|
498
|
+
values = [str(v) for v in (helm_raw.get("valueFiles") or [])]
|
499
|
+
return AppHelmConfig(release_name=release, value_files=values)
|
500
|
+
|
501
|
+
|
502
|
+
def _parse_secret_values(seq: Any) -> list[SecretValueFromEnv]:
|
503
|
+
if not isinstance(seq, list):
|
504
|
+
return []
|
505
|
+
return [
|
506
|
+
SecretValueFromEnv(from_env=str(v.get("fromEnv", "")))
|
507
|
+
for v in seq
|
508
|
+
if isinstance(v, dict)
|
509
|
+
]
|
510
|
+
|
511
|
+
|
512
|
+
def _coerce_single_key_mapping(item: Any, ctx: str) -> tuple[str, dict[str, Any]]:
|
513
|
+
if not isinstance(item, dict) or len(item) != 1:
|
514
|
+
msg = f"{ctx} must be a single-key mapping of name to spec"
|
515
|
+
raise ManifestValidationError(msg)
|
516
|
+
name, spec_any = next(iter(item.items()))
|
517
|
+
if not isinstance(spec_any, dict):
|
518
|
+
msg = f"{ctx}.{name} spec must be a mapping"
|
519
|
+
raise ManifestValidationError(msg)
|
520
|
+
return str(name), spec_any
|
@@ -0,0 +1,66 @@
|
|
1
|
+
"""YAML-backed persistent config store for LocalArgo.
|
2
|
+
|
3
|
+
Supports default path at ~/.localargo/config.yaml and override via
|
4
|
+
LOCALARGO_CONFIG environment variable. Provides simple load/save helpers
|
5
|
+
and a small class wrapper to interact with nested dict-like configs.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
import os
|
11
|
+
from dataclasses import dataclass, field
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import Any
|
14
|
+
|
15
|
+
import yaml
|
16
|
+
|
17
|
+
DEFAULT_CONFIG_RELATIVE = Path(".localargo/config.yaml")
|
18
|
+
ENV_OVERRIDE = "LOCALARGO_CONFIG"
|
19
|
+
|
20
|
+
|
21
|
+
def _resolve_config_path() -> Path:
|
22
|
+
"""Resolve the config file path honoring the environment override."""
|
23
|
+
env_path = os.getenv(ENV_OVERRIDE)
|
24
|
+
if env_path:
|
25
|
+
return Path(env_path).expanduser()
|
26
|
+
return Path.home() / DEFAULT_CONFIG_RELATIVE
|
27
|
+
|
28
|
+
|
29
|
+
def load_config() -> dict[str, Any]:
|
30
|
+
"""Load configuration from disk; return empty dict if missing or empty."""
|
31
|
+
path = _resolve_config_path()
|
32
|
+
if not path.exists():
|
33
|
+
return {}
|
34
|
+
with open(path, encoding="utf-8") as f:
|
35
|
+
data = yaml.safe_load(f) or {}
|
36
|
+
if not isinstance(data, dict):
|
37
|
+
return {}
|
38
|
+
return data
|
39
|
+
|
40
|
+
|
41
|
+
def save_config(config: dict[str, Any]) -> Path:
|
42
|
+
"""Persist configuration to disk, creating parent directory as needed."""
|
43
|
+
path = _resolve_config_path()
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
45
|
+
with open(path, "w", encoding="utf-8") as f:
|
46
|
+
yaml.safe_dump(config, f, sort_keys=True)
|
47
|
+
return path
|
48
|
+
|
49
|
+
|
50
|
+
@dataclass
|
51
|
+
class ConfigStore:
|
52
|
+
"""Small dict-like wrapper for LocalArgo config with persistence helpers."""
|
53
|
+
|
54
|
+
data: dict[str, Any] = field(default_factory=load_config)
|
55
|
+
|
56
|
+
def get(self, key: str, default: Any | None = None) -> Any | None:
|
57
|
+
"""Return the value for key if present, else default."""
|
58
|
+
return self.data.get(key, default)
|
59
|
+
|
60
|
+
def set(self, key: str, value: Any) -> None:
|
61
|
+
"""Set a configuration key to the provided value."""
|
62
|
+
self.data[key] = value
|
63
|
+
|
64
|
+
def save(self) -> Path:
|
65
|
+
"""Persist current configuration to disk and return the file path."""
|
66
|
+
return save_config(self.data)
|