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,509 @@
1
+ """ArgoCD client façade built on argocd CLI (and optional HTTP).
2
+
3
+ Default mode shells out to the argocd CLI via safe subprocess wrappers.
4
+ HTTP support can be added later using stdlib only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import contextlib
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from localargo.core.catalog import AppSpec, AppState
16
+ from localargo.logging import logger
17
+ from localargo.utils.proc import ProcessError, run, run_json
18
+
19
+ # Constants
20
+ LOGIN_SUBCMD_MIN_ARGS = 2
21
+
22
+
23
+ class ArgoClient:
24
+ """Thin façade around ArgoCD CLI for app lifecycle operations."""
25
+
26
+ def __init__(
27
+ self, *, namespace: str = "argocd", server: str | None = None, insecure: bool = True
28
+ ) -> None:
29
+ self.namespace = namespace
30
+ # Initial desired server; will be auto-discovered if unreachable
31
+ self.server = server or os.environ.get("ARGOCD_SERVER", "localhost:8080")
32
+ self.insecure = insecure
33
+ self._logged_in = False
34
+ self._login_cli()
35
+
36
+ # ---------- Authentication ----------
37
+ def _login_cli(self, *, force: bool = False) -> None:
38
+ if self._logged_in:
39
+ return
40
+
41
+ session_server = (
42
+ None if force else _find_server_with_valid_session(_candidate_servers(self.server))
43
+ )
44
+ if session_server is not None:
45
+ logger.info("🔑 Using existing ArgoCD session on '%s'", session_server)
46
+ self.server = session_server
47
+ self._logged_in = True
48
+ return
49
+
50
+ _logout_stale_session()
51
+
52
+ password = _get_initial_admin_password(self.namespace)
53
+ login_server = _login_first_success(
54
+ _candidate_servers(self.server), password, insecure=self.insecure
55
+ )
56
+ if login_server is None:
57
+ # Provide a clearer error than argocd's raw exit code
58
+ msg = "Failed to authenticate with ArgoCD on any known server"
59
+ raise ProcessError(msg, code=1, stdout="", stderr=msg)
60
+ logger.info("✅ Authenticated to ArgoCD at '%s'", login_server)
61
+ self.server = login_server
62
+ self._logged_in = True
63
+
64
+ def run_with_auth(self, args: list[str]) -> str:
65
+ """Run a CLI command ensuring authentication; retry once after login.
66
+
67
+ For commands that return text stdout and non-JSON output.
68
+ """
69
+ try:
70
+ return run(_with_server(args, self.server, insecure=self.insecure))
71
+ except ProcessError:
72
+ # Attempt re-login and retry once
73
+ self._logged_in = False
74
+ logger.info("🔄 ArgoCD command failed; re-authenticating and retrying...")
75
+ self._login_cli(force=True)
76
+ return run(_with_server(args, self.server, insecure=self.insecure))
77
+
78
+ def run_json_with_auth(self, args: list[str]) -> Any:
79
+ """Run a CLI command producing JSON ensuring authentication; single retry."""
80
+ try:
81
+ return run_json(_with_server(args, self.server, insecure=self.insecure))
82
+ except ProcessError:
83
+ self._logged_in = False
84
+ logger.info("🔄 ArgoCD command failed; re-authenticating and retrying...")
85
+ self._login_cli(force=True)
86
+ return run_json(_with_server(args, self.server, insecure=self.insecure))
87
+
88
+ # ---------- App lifecycle ----------
89
+ def create_or_update_app(self, spec: AppSpec) -> None:
90
+ """Create the app if missing, otherwise update it; applies sync policy."""
91
+ args = _build_create_args(spec)
92
+ _run_create_or_update(self, args, spec, self.update_app)
93
+ _apply_sync_policy_if_auto(self, spec)
94
+
95
+ def update_app(self, spec: AppSpec) -> None:
96
+ """Update an existing ArgoCD application."""
97
+ args = _build_update_args(spec)
98
+ self.run_with_auth(args)
99
+
100
+ def sync_app(
101
+ self, name: str, *, wait: bool = False, timeout: int = 300, force: bool = False
102
+ ) -> str:
103
+ """Trigger app sync; optionally wait for Healthy and return final health."""
104
+ args = ["argocd", "app", "sync", name]
105
+ if force:
106
+ args.append("--force")
107
+ self.run_with_auth(args)
108
+ if wait:
109
+ return self.wait_healthy(name, timeout=timeout)
110
+ return "Unknown"
111
+
112
+ def wait_healthy(self, name: str, *, timeout: int = 300) -> str:
113
+ """Wait for Healthy using ArgoCD CLI and raise helpful error on timeout."""
114
+ succeeded = True
115
+ try:
116
+ # Delegate waiting logic to ArgoCD; it honors rollout states and dependencies
117
+ self.run_with_auth(
118
+ ["argocd", "app", "wait", name, "--health", "--timeout", str(timeout)]
119
+ )
120
+ except ProcessError:
121
+ succeeded = False
122
+ if succeeded:
123
+ return "Healthy"
124
+ summary = self._summarize_unhealthy(name)
125
+ msg = f"{name} not healthy after {timeout}s" + (f": {summary}" if summary else "")
126
+ raise TimeoutError(msg)
127
+
128
+ def _summarize_unhealthy(self, name: str) -> str:
129
+ """Return a one-line summary of the first non-Healthy resource, if any."""
130
+ try:
131
+ # Fetch app JSON via CLI
132
+ raw: Any = self.run_json_with_auth(
133
+ [
134
+ "argocd",
135
+ "app",
136
+ "get",
137
+ name,
138
+ "-o",
139
+ "json",
140
+ ]
141
+ )
142
+ except ProcessError:
143
+ return ""
144
+ resources = self._get_resources_from_app_json(raw)
145
+ info = self._first_unhealthy_resource(resources)
146
+ if not info:
147
+ return ""
148
+ kind, res_name, h_status, message = info
149
+ suffix = f" - {message}" if message else ""
150
+ return f"{kind}/{res_name}: {h_status}{suffix}"
151
+
152
+ def _get_resources_from_app_json(self, obj: Any) -> list[dict[str, Any]]:
153
+ """Extract the resources array from an argocd app JSON object."""
154
+ if not isinstance(obj, dict):
155
+ return []
156
+ status = obj.get("status", {})
157
+ if not isinstance(status, dict):
158
+ return []
159
+ resources = status.get("resources", [])
160
+ if not isinstance(resources, list):
161
+ return []
162
+ return [r for r in resources if isinstance(r, dict)]
163
+
164
+ def _first_unhealthy_resource(
165
+ self, resources: list[dict[str, Any]]
166
+ ) -> tuple[str, str, str, str] | None:
167
+ """Return tuple(kind, name, status, message) for the first unhealthy resource."""
168
+ for res in resources:
169
+ health = res.get("health", {})
170
+ if not isinstance(health, dict):
171
+ continue
172
+ h_status = str(health.get("status") or "")
173
+ if h_status and h_status != "Healthy":
174
+ kind = str(res.get("kind", ""))
175
+ name_val = str(res.get("name", ""))
176
+ message = str(health.get("message", ""))
177
+ return (kind, name_val, h_status, message)
178
+ return None
179
+
180
+ def get_apps(self) -> list[AppState]:
181
+ """Return all ArgoCD apps as AppState list."""
182
+ out = self.run_json_with_auth(["argocd", "app", "list", "-o", "json"])
183
+ if not isinstance(out, list):
184
+ return []
185
+ return [to_state(x) for x in out]
186
+
187
+ def get_app(self, name: str) -> AppState:
188
+ """Return AppState for a single ArgoCD application."""
189
+ out = self.run_json_with_auth(["argocd", "app", "get", name, "-o", "json"])
190
+ return to_state(out)
191
+
192
+ def delete_app(self, name: str) -> None:
193
+ """Delete an ArgoCD application by name."""
194
+ self.run_with_auth(["argocd", "app", "delete", name, "--yes"])
195
+
196
+ # ---------- Repo credentials ----------
197
+ def add_repo_cred(
198
+ self,
199
+ *,
200
+ repo_url: str,
201
+ username: str,
202
+ password: str,
203
+ options: RepoAddOptions | None = None,
204
+ ) -> None:
205
+ """Add repository credentials to ArgoCD (supports git and helm OCI)."""
206
+ args = _build_repo_add_args(
207
+ repo_url=repo_url,
208
+ username=username,
209
+ password=password,
210
+ options=options or RepoAddOptions(),
211
+ )
212
+ try:
213
+ self.run_with_auth(args)
214
+ except ProcessError as e:
215
+ if _repo_already_configured(e.stderr):
216
+ logger.info("Repo creds for %s already exist", repo_url)
217
+ else:
218
+ logger.error(
219
+ "Failed to add repo creds for %s. stderr: %s",
220
+ repo_url,
221
+ (e.stderr or "").strip(),
222
+ )
223
+ raise
224
+
225
+
226
+ def _repo_already_configured(stderr: str | None) -> bool:
227
+ msg = stderr or ""
228
+ return (
229
+ "AlreadyExists" in msg
230
+ or "already associated" in msg
231
+ or "repository is already configured" in msg
232
+ )
233
+
234
+
235
+ @dataclass
236
+ class RepoAddOptions:
237
+ """Options to control 'argocd repo add' invocation."""
238
+
239
+ repo_type: str = "git"
240
+ enable_oci: bool = False
241
+ description: str | None = None
242
+ name: str | None = None
243
+
244
+
245
+ def _build_repo_add_args(
246
+ *, repo_url: str, username: str, password: str, options: RepoAddOptions
247
+ ) -> list[str]:
248
+ args = [
249
+ "argocd",
250
+ "repo",
251
+ "add",
252
+ repo_url,
253
+ "--username",
254
+ username,
255
+ "--password",
256
+ password,
257
+ ]
258
+ if options.repo_type:
259
+ args.extend(["--type", options.repo_type])
260
+ if options.enable_oci:
261
+ args.append("--enable-oci")
262
+ # For Helm (incl. OCI) repos, argocd requires a --name
263
+ if options.repo_type == "helm":
264
+ repo_name = options.name or _derive_repo_name(repo_url)
265
+ if repo_name:
266
+ args.extend(["--name", repo_name])
267
+ # --description is not supported by many argocd CLI versions; omit for compatibility
268
+ return args
269
+
270
+
271
+ def _derive_repo_name(repo_url: str) -> str:
272
+ """Derive a short name from a repo URL like 'registry-1.docker.io/bitnamicharts'."""
273
+ parts = repo_url.rstrip("/").split("/")
274
+ return parts[-1] if parts else repo_url
275
+
276
+
277
+ def _get_initial_admin_password(namespace: str) -> str:
278
+ # base64-encoded password from secret
279
+ jsonpath = "jsonpath={.data.password}"
280
+ data = run(
281
+ [
282
+ "kubectl",
283
+ "-n",
284
+ namespace,
285
+ "get",
286
+ "secret",
287
+ "argocd-initial-admin-secret",
288
+ "-o",
289
+ jsonpath,
290
+ ]
291
+ )
292
+ return base64.b64decode(data.strip()).decode("utf-8")
293
+
294
+
295
+ def to_state(obj: dict[str, Any]) -> AppState:
296
+ """Convert ArgoCD app JSON object into an AppState instance."""
297
+ name = _get_name(obj)
298
+ namespace = _get_namespace(obj)
299
+ health = _get_health(obj)
300
+ sync = _get_sync(obj)
301
+ revision = _dig(obj, ["status", "sync", "revision"]) or None
302
+ return AppState(
303
+ name=str(name),
304
+ namespace=str(namespace),
305
+ health=str(health), # type: ignore[arg-type]
306
+ sync=str(sync), # type: ignore[arg-type]
307
+ revision=str(revision) if revision is not None else None,
308
+ )
309
+
310
+
311
+ def _dig(obj: Any, path: list[str]) -> Any:
312
+ cur: Any = obj
313
+ for key in path:
314
+ if not isinstance(cur, dict) or key not in cur:
315
+ return None
316
+ cur = cur[key]
317
+ return cur
318
+
319
+
320
+ def _build_create_args(spec: AppSpec) -> list[str]:
321
+ args: list[str] = [
322
+ "argocd",
323
+ "app",
324
+ "create",
325
+ spec.name,
326
+ "--repo",
327
+ spec.repo,
328
+ "--path",
329
+ spec.path,
330
+ "--dest-server",
331
+ "https://kubernetes.default.svc",
332
+ "--dest-namespace",
333
+ spec.namespace,
334
+ "--project",
335
+ spec.project,
336
+ ]
337
+ if spec.type == "helm":
338
+ # ArgoCD expects --values and optionally --release-name for Helm apps;
339
+ # it does not support a standalone --helm flag.
340
+ for v in spec.helm_values:
341
+ args.extend(["--values", v])
342
+ return args
343
+
344
+
345
+ def _build_update_args(spec: AppSpec) -> list[str]:
346
+ args: list[str] = [
347
+ "argocd",
348
+ "app",
349
+ "set",
350
+ spec.name,
351
+ "--repo",
352
+ spec.repo,
353
+ "--path",
354
+ spec.path,
355
+ "--dest-server",
356
+ "https://kubernetes.default.svc",
357
+ "--dest-namespace",
358
+ spec.namespace,
359
+ "--project",
360
+ spec.project,
361
+ ]
362
+ if spec.type == "helm":
363
+ # For Helm apps, keep --values; do not add unsupported --helm flag
364
+ for v in spec.helm_values:
365
+ args.extend(["--values", v])
366
+ return args
367
+
368
+
369
+ def _run_create_or_update(
370
+ client: ArgoClient, args: list[str], spec: AppSpec, updater: Any
371
+ ) -> None:
372
+ try:
373
+ client.run_with_auth(args)
374
+ except ProcessError as e:
375
+ if "already exists" in (e.stderr or ""):
376
+ updater(spec)
377
+ else:
378
+ raise
379
+
380
+
381
+ def _apply_sync_policy_if_auto(client: ArgoClient, spec: AppSpec) -> None:
382
+ if spec.sync_policy == "auto":
383
+ client.run_with_auth(["argocd", "app", "set", spec.name, "--sync-policy", "auto"])
384
+
385
+
386
+ def _get_name(obj: dict[str, Any]) -> str:
387
+ return str(_dig(obj, ["metadata", "name"]) or obj.get("name", ""))
388
+
389
+
390
+ def _get_namespace(obj: dict[str, Any]) -> str:
391
+ return str(
392
+ _dig(obj, ["spec", "destination", "namespace"]) or obj.get("namespace", "default")
393
+ )
394
+
395
+
396
+ def _get_health(obj: dict[str, Any]) -> str:
397
+ return str(_dig(obj, ["status", "health", "status"]) or "Unknown")
398
+
399
+
400
+ def _get_sync(obj: dict[str, Any]) -> str:
401
+ return str(_dig(obj, ["status", "sync", "status"]) or "Unknown")
402
+
403
+
404
+ def _candidate_servers(preferred: str) -> list[str]:
405
+ """Return candidate ArgoCD server hosts to try for login.
406
+
407
+ Includes the preferred value, common local endpoints, and de-duplicates while
408
+ preserving order.
409
+ """
410
+ candidates = [
411
+ preferred,
412
+ "argocd.localtest.me",
413
+ "localhost:8080",
414
+ "127.0.0.1:8080",
415
+ ]
416
+ seen: set[str] = set()
417
+ ordered: list[str] = []
418
+ for s in candidates:
419
+ if s and s not in seen:
420
+ ordered.append(s)
421
+ seen.add(s)
422
+ return ordered
423
+
424
+
425
+ def _with_server(args: list[str], server: str, *, insecure: bool) -> list[str]:
426
+ """Append --server and --insecure to argocd commands if not present."""
427
+ if not args or args[0] != "argocd":
428
+ return args
429
+ if _is_login_cmd(args):
430
+ return _ensure_insecure_for_login(args, insecure=insecure)
431
+ return _append_server_and_insecure(args, server, insecure=insecure)
432
+
433
+
434
+ def _is_login_cmd(args: list[str]) -> bool:
435
+ """Return True if command is 'argocd login ...'."""
436
+ return len(args) >= LOGIN_SUBCMD_MIN_ARGS and args[1] == "login"
437
+
438
+
439
+ def _ensure_insecure_for_login(args: list[str], *, insecure: bool) -> list[str]:
440
+ """For login command, only ensure --insecure if requested."""
441
+ if insecure and "--insecure" not in args:
442
+ return [*args, "--insecure"]
443
+ return args
444
+
445
+
446
+ def _append_server_and_insecure(args: list[str], server: str, *, insecure: bool) -> list[str]:
447
+ """Append --server <server> and --insecure flags if missing."""
448
+ new_args = list(args)
449
+ if "--server" not in new_args and "-s" not in new_args:
450
+ new_args.extend(["--server", server])
451
+ if insecure and "--insecure" not in new_args:
452
+ new_args.append("--insecure")
453
+ return new_args
454
+
455
+
456
+ def _find_server_with_valid_session(candidates: list[str]) -> str | None:
457
+ """Return the first server that has a valid argocd CLI session, else None."""
458
+ for server in candidates:
459
+ try:
460
+ run(
461
+ ["argocd", "--server", server, "account", "get-user-info", "-o", "json"],
462
+ timeout=10,
463
+ )
464
+ except ProcessError:
465
+ pass
466
+ else:
467
+ return server
468
+ return None
469
+
470
+
471
+ def _logout_stale_session() -> None:
472
+ """Attempt to logout any existing session; ignore failures."""
473
+ with contextlib.suppress(ProcessError):
474
+ logger.info("🚪 Logging out any stale ArgoCD session...")
475
+ run(["argocd", "logout", "--yes"], timeout=5)
476
+
477
+
478
+ def _login_first_success(
479
+ candidates: list[str], password: str, *, insecure: bool
480
+ ) -> str | None:
481
+ """Try logging into each server, returning the first that succeeds."""
482
+ for server in candidates:
483
+ logger.info("🔐 Attempting ArgoCD login at '%s'", server)
484
+ base_args = [
485
+ "argocd",
486
+ "login",
487
+ server,
488
+ "--username",
489
+ "admin",
490
+ "--password",
491
+ password,
492
+ ]
493
+ if insecure:
494
+ base_args.append("--insecure")
495
+
496
+ try:
497
+ run(base_args, timeout=20)
498
+ except ProcessError:
499
+ args2 = [*base_args, "--grpc-web"]
500
+ logger.info("🌐 Retrying login to '%s' with --grpc-web", server)
501
+ try:
502
+ run(args2, timeout=20)
503
+ except ProcessError:
504
+ logger.info("❌ Login failed for '%s'", server)
505
+ else:
506
+ return server
507
+ else:
508
+ return server
509
+ return None