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
@@ -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