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,341 @@
|
|
1
|
+
"""Validate, up, and down commands for up-manifest flows."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import os
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
import click
|
10
|
+
|
11
|
+
from localargo.config.manifest import (
|
12
|
+
UpManifest,
|
13
|
+
load_up_manifest,
|
14
|
+
)
|
15
|
+
from localargo.core.argocd import ArgoClient, RepoAddOptions
|
16
|
+
from localargo.core.k8s import apply_manifests, ensure_namespace, upsert_secret
|
17
|
+
from localargo.logging import logger
|
18
|
+
from localargo.providers.registry import get_provider
|
19
|
+
from localargo.utils.cli import (
|
20
|
+
ensure_core_tools_available,
|
21
|
+
)
|
22
|
+
from localargo.utils.proc import ProcessError
|
23
|
+
|
24
|
+
|
25
|
+
def _default_manifest_path(manifest: str | None) -> str:
|
26
|
+
if manifest:
|
27
|
+
return manifest
|
28
|
+
# Prefer ./localargo.yaml in CWD
|
29
|
+
default = Path.cwd() / "localargo.yaml"
|
30
|
+
return str(default)
|
31
|
+
|
32
|
+
|
33
|
+
@click.command()
|
34
|
+
@click.option("--manifest", "manifest_path", default=None, help="Path to localargo.yaml")
|
35
|
+
def validate_cmd(manifest_path: str | None) -> None:
|
36
|
+
"""Validate the manifest and print steps that would be executed."""
|
37
|
+
manifest_file = _default_manifest_path(manifest_path)
|
38
|
+
ensure_core_tools_available()
|
39
|
+
upm = load_up_manifest(manifest_file)
|
40
|
+
_print_planned_steps(upm)
|
41
|
+
|
42
|
+
|
43
|
+
def _print_planned_steps(upm: UpManifest) -> None:
|
44
|
+
cluster = upm.clusters[0]
|
45
|
+
logger.info("Planned steps:")
|
46
|
+
logger.info("1) Create cluster '%s' with provider '%s'", cluster.name, cluster.provider)
|
47
|
+
logger.info("2) Login to ArgoCD using initial admin secret")
|
48
|
+
_print_secrets_plan(upm)
|
49
|
+
_print_repo_creds(upm)
|
50
|
+
_print_apps_plan(upm)
|
51
|
+
|
52
|
+
|
53
|
+
def _print_repo_creds(upm: UpManifest) -> None:
|
54
|
+
if upm.repo_creds:
|
55
|
+
logger.info("4) Add %d repo credential(s):", len(upm.repo_creds))
|
56
|
+
for rc in upm.repo_creds:
|
57
|
+
kind = getattr(rc, "type", "git")
|
58
|
+
oci = " [OCI]" if getattr(rc, "enable_oci", False) else ""
|
59
|
+
name = getattr(rc, "name", None)
|
60
|
+
if name and kind == "helm":
|
61
|
+
logger.info(
|
62
|
+
" - repo: %s (type: %s%s, name: %s, username: %s)",
|
63
|
+
rc.repo_url,
|
64
|
+
kind,
|
65
|
+
oci,
|
66
|
+
name,
|
67
|
+
rc.username,
|
68
|
+
)
|
69
|
+
else:
|
70
|
+
logger.info(
|
71
|
+
" - repo: %s (type: %s%s, username: %s)",
|
72
|
+
rc.repo_url,
|
73
|
+
kind,
|
74
|
+
oci,
|
75
|
+
rc.username,
|
76
|
+
)
|
77
|
+
else:
|
78
|
+
logger.info("4) No repo credentials to add")
|
79
|
+
|
80
|
+
|
81
|
+
def _print_secrets_plan(upm: UpManifest) -> None:
|
82
|
+
if upm.secrets:
|
83
|
+
logger.info("3) Create/Update %d Kubernetes secret(s):", len(upm.secrets))
|
84
|
+
for sec in upm.secrets:
|
85
|
+
sources = (
|
86
|
+
", ".join([v.from_env for v in sec.secret_value if v.from_env]) or "<empty>"
|
87
|
+
)
|
88
|
+
logger.info(
|
89
|
+
" - [%s] secret '%s': set key '%s' from env: %s",
|
90
|
+
sec.namespace,
|
91
|
+
sec.secret_name,
|
92
|
+
sec.secret_key,
|
93
|
+
sources,
|
94
|
+
)
|
95
|
+
else:
|
96
|
+
logger.info("3) No secrets to create/update")
|
97
|
+
|
98
|
+
|
99
|
+
def _print_apps_plan(upm: UpManifest) -> None:
|
100
|
+
if not upm.apps:
|
101
|
+
logger.info("5) No applications to deploy")
|
102
|
+
return
|
103
|
+
logger.info("5) Create/Update and sync %d application(s):", len(upm.apps))
|
104
|
+
for app in upm.apps:
|
105
|
+
_print_single_app_plan(app)
|
106
|
+
|
107
|
+
|
108
|
+
def _print_single_app_plan(app: Any) -> None:
|
109
|
+
logger.info(" - app '%s':", app.name)
|
110
|
+
_print_app_namespace(app)
|
111
|
+
if getattr(app, "sources", None):
|
112
|
+
_print_sources_details(app)
|
113
|
+
else:
|
114
|
+
_print_single_source_details(app)
|
115
|
+
logger.info(" actions: create-or-update, sync --wait")
|
116
|
+
|
117
|
+
|
118
|
+
def _print_app_namespace(app: Any) -> None:
|
119
|
+
if getattr(app, "namespace", None):
|
120
|
+
logger.info(" namespace: %s", app.namespace)
|
121
|
+
|
122
|
+
|
123
|
+
def _print_sources_details(app: Any) -> None:
|
124
|
+
for s in app.sources:
|
125
|
+
repo = getattr(s, "repo_url", "")
|
126
|
+
rev = getattr(s, "target_revision", "")
|
127
|
+
path = getattr(s, "path", None)
|
128
|
+
chart = getattr(s, "chart", None)
|
129
|
+
if chart:
|
130
|
+
logger.info(" source: repo=%s chart=%s revision=%s", repo, chart, rev)
|
131
|
+
else:
|
132
|
+
logger.info(" source: repo=%s path=%s revision=%s", repo, path or ".", rev)
|
133
|
+
_print_helm_details(getattr(s, "helm", None))
|
134
|
+
|
135
|
+
|
136
|
+
def _print_helm_details(helm_cfg: Any | None) -> None:
|
137
|
+
if helm_cfg and helm_cfg.value_files:
|
138
|
+
logger.info(" helm values: %s", ", ".join(helm_cfg.value_files))
|
139
|
+
if helm_cfg and getattr(helm_cfg, "release_name", None):
|
140
|
+
logger.info(" helm release: %s", helm_cfg.release_name)
|
141
|
+
|
142
|
+
|
143
|
+
def _print_single_source_details(app: Any) -> None:
|
144
|
+
logger.info(
|
145
|
+
" repo=%s path=%s revision=%s", app.repo_url, app.path, app.target_revision
|
146
|
+
)
|
147
|
+
|
148
|
+
|
149
|
+
@click.command()
|
150
|
+
@click.option("--manifest", "manifest_path", default=None, help="Path to localargo.yaml")
|
151
|
+
def up_cmd(manifest_path: str | None) -> None:
|
152
|
+
"""Bring up cluster, configure ArgoCD, apply secrets, deploy apps."""
|
153
|
+
manifest_file = _default_manifest_path(manifest_path)
|
154
|
+
ensure_core_tools_available()
|
155
|
+
upm = load_up_manifest(manifest_file)
|
156
|
+
|
157
|
+
_create_cluster(upm)
|
158
|
+
|
159
|
+
# 2) Login to ArgoCD
|
160
|
+
client = ArgoClient(namespace="argocd", insecure=True)
|
161
|
+
|
162
|
+
# Apply secrets before configuring repo credentials so apps can pull needed values
|
163
|
+
_apply_secrets(upm)
|
164
|
+
|
165
|
+
_add_repo_creds(client, upm)
|
166
|
+
|
167
|
+
_deploy_apps(client, upm)
|
168
|
+
|
169
|
+
logger.info("✅ Up complete")
|
170
|
+
|
171
|
+
|
172
|
+
def _create_cluster(upm: UpManifest) -> None:
|
173
|
+
cluster = upm.clusters[0]
|
174
|
+
provider_cls = get_provider(cluster.provider)
|
175
|
+
provider = provider_cls(name=cluster.name)
|
176
|
+
logger.info("Creating cluster '%s' with provider '%s'...", cluster.name, cluster.provider)
|
177
|
+
provider.create_cluster(**cluster.kwargs)
|
178
|
+
|
179
|
+
|
180
|
+
def _add_repo_creds(client: ArgoClient, upm: UpManifest) -> None:
|
181
|
+
for rc in upm.repo_creds:
|
182
|
+
logger.info("Adding repo creds for %s", rc.repo_url)
|
183
|
+
client.add_repo_cred(
|
184
|
+
repo_url=rc.repo_url,
|
185
|
+
username=rc.username,
|
186
|
+
password=rc.password,
|
187
|
+
options=RepoAddOptions(
|
188
|
+
repo_type=getattr(rc, "type", "git"),
|
189
|
+
enable_oci=getattr(rc, "enable_oci", False),
|
190
|
+
description=getattr(rc, "description", None),
|
191
|
+
name=getattr(rc, "name", None),
|
192
|
+
),
|
193
|
+
)
|
194
|
+
|
195
|
+
|
196
|
+
def _apply_secrets(upm: UpManifest) -> None:
|
197
|
+
for sec in upm.secrets:
|
198
|
+
env_map: dict[str, str] = {}
|
199
|
+
for v in sec.secret_value:
|
200
|
+
env_map[sec.secret_key] = os.environ.get(v.from_env, "")
|
201
|
+
ensure_namespace(sec.namespace)
|
202
|
+
upsert_secret(sec.namespace, sec.secret_name, env_map)
|
203
|
+
|
204
|
+
|
205
|
+
def _deploy_apps(client: ArgoClient, upm: UpManifest) -> None:
|
206
|
+
for app in upm.apps:
|
207
|
+
# Ensure destination namespace exists prior to ArgoCD applying resources
|
208
|
+
if getattr(app, "namespace", None):
|
209
|
+
ensure_namespace(app.namespace)
|
210
|
+
# If app_file provided, apply Application YAML directly; otherwise use CLI
|
211
|
+
if getattr(app, "app_file", None):
|
212
|
+
apply_manifests([str(app.app_file)])
|
213
|
+
# Rely on Application's sync policy; skip CLI sync to avoid RBAC issues
|
214
|
+
continue
|
215
|
+
_create_or_update_app(client, app)
|
216
|
+
client.sync_app(app.name, wait=True)
|
217
|
+
|
218
|
+
|
219
|
+
def _create_or_update_app(client: ArgoClient, app: Any) -> None:
|
220
|
+
create_args = _build_app_args(app, create=True)
|
221
|
+
try:
|
222
|
+
client.run_with_auth(create_args)
|
223
|
+
except ProcessError: # update on existence or other benign failures
|
224
|
+
update_args = _build_app_args(app, create=False)
|
225
|
+
try:
|
226
|
+
client.run_with_auth(update_args)
|
227
|
+
except ProcessError:
|
228
|
+
logger.error(
|
229
|
+
"Failed to create or update app '%s'.\nCreate args: %s\nUpdate args: %s",
|
230
|
+
app.name,
|
231
|
+
" ".join(create_args),
|
232
|
+
" ".join(update_args),
|
233
|
+
)
|
234
|
+
raise
|
235
|
+
|
236
|
+
|
237
|
+
def _build_app_args(app: Any, *, create: bool) -> list[str]:
|
238
|
+
base = ["argocd", "app", "create" if create else "set", app.name]
|
239
|
+
_append_repo_path_classic(base, app)
|
240
|
+
_append_destination(base, app)
|
241
|
+
_append_revision_and_helm(base, app)
|
242
|
+
return base
|
243
|
+
|
244
|
+
|
245
|
+
def _append_repo_path_classic(base: list[str], app: Any) -> None:
|
246
|
+
sources = getattr(app, "sources", None) or []
|
247
|
+
# Use first source only; older argocd CLI lacks --source support
|
248
|
+
if sources:
|
249
|
+
s = sources[0]
|
250
|
+
repo = getattr(s, "repo_url", app.repo_url)
|
251
|
+
path = getattr(s, "path", app.path)
|
252
|
+
chart = getattr(s, "chart", None)
|
253
|
+
base.extend(["--repo", repo])
|
254
|
+
if chart:
|
255
|
+
base.extend(["--helm-chart", chart])
|
256
|
+
else:
|
257
|
+
base.extend(["--path", path or "."])
|
258
|
+
# Merge helm flags from the source into top-level handling
|
259
|
+
_append_source_helm_filtered(base, getattr(s, "helm", None), is_chart=bool(chart))
|
260
|
+
# Prefer source revision over top-level when provided
|
261
|
+
if getattr(s, "target_revision", None):
|
262
|
+
# Prefer source-specified revision; _append_revision_and_helm will use this
|
263
|
+
app.target_revision = s.target_revision
|
264
|
+
return
|
265
|
+
base.extend(["--repo", app.repo_url, "--path", app.path])
|
266
|
+
|
267
|
+
|
268
|
+
def _append_destination(base: list[str], app: Any) -> None:
|
269
|
+
base.extend(
|
270
|
+
[
|
271
|
+
"--dest-server",
|
272
|
+
"https://kubernetes.default.svc",
|
273
|
+
"--dest-namespace",
|
274
|
+
getattr(app, "namespace", "default"),
|
275
|
+
]
|
276
|
+
)
|
277
|
+
|
278
|
+
|
279
|
+
def _append_revision_and_helm(base: list[str], app: Any) -> None:
|
280
|
+
# Ensure single --revision occurrence
|
281
|
+
base.extend(["--revision", app.target_revision])
|
282
|
+
# If sources exist, we already appended any per-source helm flags; avoid duplicates
|
283
|
+
if getattr(app, "sources", None):
|
284
|
+
return
|
285
|
+
if getattr(app, "helm", None):
|
286
|
+
if app.helm.release_name:
|
287
|
+
base.extend(["--release-name", app.helm.release_name])
|
288
|
+
for v in getattr(app.helm, "value_files", []) or []:
|
289
|
+
base.extend(["--values", v])
|
290
|
+
|
291
|
+
|
292
|
+
def _append_source_helm_filtered(base: list[str], hcfg: Any | None, *, is_chart: bool) -> None:
|
293
|
+
if hcfg and getattr(hcfg, "release_name", None):
|
294
|
+
base.extend(["--release-name", hcfg.release_name])
|
295
|
+
if hcfg and getattr(hcfg, "value_files", None):
|
296
|
+
for v in _filter_values_for_chart(hcfg.value_files, is_chart=is_chart):
|
297
|
+
base.extend(["--values", v])
|
298
|
+
|
299
|
+
|
300
|
+
def _filter_values_for_chart(values: list[str], *, is_chart: bool) -> list[str]:
|
301
|
+
"""Filter helm values for chart repos to avoid external or env paths."""
|
302
|
+
if not is_chart:
|
303
|
+
return list(values)
|
304
|
+
return [v for v in values if v and "/" not in v and not v.startswith("$")]
|
305
|
+
|
306
|
+
|
307
|
+
def _compose_source_arg(s: Any) -> str:
|
308
|
+
# No longer used; kept for reference compatibility with newer argocd CLIs
|
309
|
+
parts: list[str] = []
|
310
|
+
repo = getattr(s, "repo_url", None)
|
311
|
+
path = getattr(s, "path", None)
|
312
|
+
chart = getattr(s, "chart", None)
|
313
|
+
rev = getattr(s, "target_revision", None)
|
314
|
+
ref = getattr(s, "ref", None)
|
315
|
+
if repo:
|
316
|
+
parts.append(f"repoURL={repo}")
|
317
|
+
if path:
|
318
|
+
parts.append(f"path={path}")
|
319
|
+
if chart:
|
320
|
+
parts.append(f"chart={chart}")
|
321
|
+
if rev:
|
322
|
+
parts.append(f"targetRevision={rev}")
|
323
|
+
if ref:
|
324
|
+
parts.append(f"ref={ref}")
|
325
|
+
return ",".join(parts)
|
326
|
+
|
327
|
+
|
328
|
+
@click.command()
|
329
|
+
@click.option("--manifest", "manifest_path", default=None, help="Path to localargo.yaml")
|
330
|
+
def down_cmd(manifest_path: str | None) -> None:
|
331
|
+
"""Tear down cluster; equivalent to `localargo cluster delete <name>`."""
|
332
|
+
manifest_file = _default_manifest_path(manifest_path)
|
333
|
+
ensure_core_tools_available()
|
334
|
+
upm = load_up_manifest(manifest_file)
|
335
|
+
|
336
|
+
cluster = upm.clusters[0]
|
337
|
+
provider_cls = get_provider(cluster.provider)
|
338
|
+
provider = provider_cls(name=cluster.name)
|
339
|
+
logger.info("Deleting cluster '%s' with provider '%s'...", cluster.name, cluster.provider)
|
340
|
+
provider.delete_cluster(cluster.name)
|
341
|
+
logger.info("✅ Down complete")
|
@@ -0,0 +1,15 @@
|
|
1
|
+
"""Configuration helpers for LocalArgo."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from localargo.config.store import ConfigStore, load_config, save_config # re-export
|
6
|
+
|
7
|
+
__all__ = [
|
8
|
+
"ConfigStore",
|
9
|
+
"load_config",
|
10
|
+
"save_config",
|
11
|
+
]
|
12
|
+
|
13
|
+
# SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
|
14
|
+
#
|
15
|
+
# SPDX-License-Identifier: MIT
|