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
localargo/core/argocd.py
ADDED
@@ -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
|