salt-api-cli 1.2.0__tar.gz → 1.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -62,10 +62,10 @@ def _run_client(cfg: Config, client: str, args: argparse.Namespace) -> None:
62
62
 
63
63
 
64
64
  def _run_state(cfg: Config, args: argparse.Namespace) -> None:
65
- def local(**kw: Any) -> dict[str, Any]:
66
- return call(cfg, "local", **kw)
65
+ def client(name: str, **kw: Any) -> dict[str, Any]:
66
+ return call(cfg, name, **kw)
67
67
 
68
- highlevel.run_state(args, local)
68
+ highlevel.run_state(args, client)
69
69
 
70
70
 
71
71
  def _run_keys(cfg: Config, args: argparse.Namespace) -> None:
@@ -0,0 +1,538 @@
1
+ """High-level, human-readable commands for salt-api-cli.
2
+
3
+ The low-level commands (``local`` / ``runner`` / ``wheel``) are thin
4
+ passthroughs that dump raw salt-api JSON. The commands here are the
5
+ opposite: each knows the *shape* of a specific salt workflow and renders it
6
+ with :mod:`rich` for a human at a terminal, layered over the low-level
7
+ client in :mod:`salt_api_cli.lowlevel`.
8
+
9
+ * ``run_state`` — the ``salt state`` command (``highstate`` / ``apply`` /
10
+ ``test``). It fires the ``state.*`` job through the ``local_async`` client
11
+ (which returns a job id immediately, dodging the proxy/gateway connection
12
+ cap that kills a long synchronous highstate) and then polls the ``runner``
13
+ ``jobs.lookup_jid`` for results, showing a progress bar as minions report
14
+ back and rendering the coloured per-minion tables once the run completes —
15
+ instead of the wall of JSON the raw ``local`` command would emit.
16
+ * ``run_keys`` — the ``salt keys`` command, layered over ``wheel key.*``.
17
+ ``keys list`` shows one coloured panel per acceptance status (Accepted /
18
+ Pending / Denied / Rejected).
19
+
20
+ Each command receives an injected ``call`` callable (bound to the right
21
+ client in cli.py), so this module never owns transport details. Colour and
22
+ box-drawing are handled by ``rich.Console``, which auto-disables them when
23
+ output is piped to a file or pager.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import sys
31
+ import time
32
+ from typing import Any, Callable, cast
33
+
34
+ from rich.columns import Columns
35
+ from rich.console import Console, Group
36
+ from rich.live import Live
37
+ from rich.padding import Padding
38
+ from rich.panel import Panel
39
+ from rich.spinner import Spinner
40
+ from rich.table import Table
41
+ from rich.text import Text
42
+
43
+ from salt_api_cli.lowlevel import SaltApiError, split_args
44
+
45
+ console = Console()
46
+
47
+ # (ASCII marker, rich style) for each per-state status. ASCII markers stay
48
+ # legible on any console; rich supplies the colour.
49
+ _STATUS_STYLE = {
50
+ "ok": ("+", "green"), # ran, no changes
51
+ "change": ("*", "green"), # ran, made changes
52
+ "diff": ("~", "yellow"), # test=True: would change
53
+ "fail": ("X", "bold red"), # failed
54
+ "skip": (".", "dim"), # requisites unmet, not run
55
+ }
56
+
57
+ # wheel key.list_all groups minion IDs under these keys; each renders as a
58
+ # panel whose border colour signals the acceptance status.
59
+ _KEY_PANELS = {
60
+ "minions": ("Accepted", "green"),
61
+ "minions_pre": ("Pending", "yellow"),
62
+ "minions_denied": ("Denied", "red"),
63
+ "minions_rejected": ("Rejected", "red"),
64
+ }
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # state rendering
69
+ # --------------------------------------------------------------------------
70
+
71
+
72
+ def _is_state_return(val: Any) -> bool:
73
+ """True if ``val`` is a state return: a non-empty dict whose every value
74
+ is itself a dict carrying a ``result`` key (the per-state record shape)."""
75
+ if not isinstance(val, dict) or not val:
76
+ return False
77
+ records = cast("dict[str, Any]", val)
78
+ return all(isinstance(v, dict) and "result" in v for v in records.values())
79
+
80
+
81
+ def _state_status(state: dict[str, Any]) -> str:
82
+ """Classify one state record into an _STATUS_STYLE key."""
83
+ if state.get("__state_ran__") is False:
84
+ return "skip"
85
+ result = state.get("result")
86
+ if result is False:
87
+ return "fail"
88
+ if result is None:
89
+ return "diff"
90
+ return "change" if state.get("changes") else "ok"
91
+
92
+
93
+ def _state_function(key: str) -> str:
94
+ """Recover ``module.func`` from a state key like
95
+ ``cmd_|-veyon-installed_|-<name>_|-run`` -> ``cmd.run``."""
96
+ parts = key.split("_|-")
97
+ if len(parts) >= 2 and parts[-1]:
98
+ return f"{parts[0]}.{parts[-1]}"
99
+ return parts[0]
100
+
101
+
102
+ def _short(text: str, limit: int = 100) -> str:
103
+ """Collapse whitespace and truncate a comment to one tidy line."""
104
+ flat = " ".join(str(text).split())
105
+ return flat if len(flat) <= limit else flat[: limit - 3] + "..."
106
+
107
+
108
+ def _fmt_duration(ms: float) -> str:
109
+ return f"{ms / 1000:.2f}s" if ms >= 1000 else f"{ms:.0f}ms"
110
+
111
+
112
+ def _count_states(states: dict[str, Any]) -> tuple[dict[str, int], float]:
113
+ """Tally per-status counts and summed duration (ms) for one minion's run.
114
+
115
+ Shared by the per-minion summary and the fleet-wide grand total."""
116
+ counts = {k: 0 for k in _STATUS_STYLE}
117
+ total_ms = 0.0
118
+ for state in states.values():
119
+ counts[_state_status(state)] += 1
120
+ try:
121
+ total_ms += float(state.get("duration", 0) or 0)
122
+ except (TypeError, ValueError):
123
+ pass
124
+ return counts, total_ms
125
+
126
+
127
+ def _counts_str(counts: dict[str, int]) -> str:
128
+ """The status tally as markup: ``N ok N changed N would-change
129
+ N skipped N failed``. ``ok`` and ``failed`` always show; the rest only
130
+ when non-zero."""
131
+ parts = [f"[green]{counts['ok']} ok[/]"]
132
+ if counts["change"]:
133
+ parts.append(f"[green]{counts['change']} changed[/]")
134
+ if counts["diff"]:
135
+ parts.append(f"[yellow]{counts['diff']} would-change[/]")
136
+ if counts["skip"]:
137
+ parts.append(f"[dim]{counts['skip']} skipped[/]")
138
+ parts.append(
139
+ f"[red]{counts['fail']} failed[/]"
140
+ if counts["fail"]
141
+ else f"{counts['fail']} failed"
142
+ )
143
+ return " ".join(parts)
144
+
145
+
146
+ def _summary_line(counts: dict[str, int], took: str) -> str:
147
+ """:func:`_counts_str` with a trailing ``took Xs`` (a preformatted
148
+ duration)."""
149
+ return f"{_counts_str(counts)} [dim]took {took}[/]"
150
+
151
+
152
+ def _grand_totals(returns: dict[str, Any]) -> tuple[dict[str, int], int]:
153
+ """Sum state counts across every minion that produced a state return,
154
+ plus the number of such minions."""
155
+ totals = {k: 0 for k in _STATUS_STYLE}
156
+ n = 0
157
+ for val in returns.values():
158
+ if not _is_state_return(val):
159
+ continue
160
+ n += 1
161
+ counts, _ = _count_states(val)
162
+ for k in totals:
163
+ totals[k] += counts[k]
164
+ return totals, n
165
+
166
+
167
+ def _print_state_return(minion: str, states: dict[str, Any]) -> None:
168
+ """Render one minion's state run: header, a table of states, summary."""
169
+ ordered = sorted(states.items(), key=lambda kv: kv[1].get("__run_num__", 1 << 30))
170
+
171
+ table = Table(box=None, show_header=False, pad_edge=False)
172
+ table.add_column("marker", no_wrap=True)
173
+ table.add_column("function", style="cyan", no_wrap=True)
174
+ table.add_column("ref", style="dim", no_wrap=True)
175
+ table.add_column("detail", no_wrap=True, overflow="ellipsis")
176
+
177
+ for key, state in ordered:
178
+ status = _state_status(state)
179
+ marker, style = _STATUS_STYLE[status]
180
+ ref = f"{state.get('__sls__', '?')}:{state.get('__id__', key)}"
181
+ if status == "ok":
182
+ detail: str | Text = ""
183
+ elif status == "change":
184
+ changed = ", ".join(state.get("changes", {})) or "(changes)"
185
+ detail = f"changed: {_short(changed)}"
186
+ elif status == "fail":
187
+ detail = Text(_short(state.get("comment", ""), 240), style="red")
188
+ else: # diff / skip
189
+ detail = _short(state.get("comment", ""))
190
+ table.add_row(Text(marker, style=style), _state_function(key), ref, detail)
191
+
192
+ counts, total_ms = _count_states(states)
193
+ console.print(Text(minion, style="bold"))
194
+ console.print(Padding(table, (0, 0, 0, 2)))
195
+ console.print(" [dim]---[/]")
196
+ console.print(f" {_summary_line(counts, _fmt_duration(total_ms))}")
197
+
198
+
199
+ def _print_one_minion(minion: str, val: Any) -> None:
200
+ """Render a single minion's return block.
201
+
202
+ A state return gets the coloured table; anything else (a render/compile
203
+ error, where salt answers with a list of message lines, or some other
204
+ shape) falls back to its lines or indented JSON."""
205
+ if _is_state_return(val):
206
+ _print_state_return(minion, val)
207
+ return
208
+ console.print(Text(minion, style="bold"))
209
+ if isinstance(val, list):
210
+ for item in cast("list[Any]", val):
211
+ console.print(Padding(Text(str(item)), (0, 0, 0, 2)))
212
+ else:
213
+ console.print(Padding(json.dumps(val, indent=2), (0, 0, 0, 2)))
214
+
215
+
216
+ def _print_state_result(result: dict[str, Any]) -> None:
217
+ """Render a state return, one block per minion (all at once).
218
+
219
+ Falls back to indented JSON for anything that isn't a state return."""
220
+ ret_list: Any = result.get("return")
221
+ if not ret_list:
222
+ console.print_json(json.dumps(result))
223
+ return
224
+ ret: dict[str, Any] = ret_list[0]
225
+ if not ret:
226
+ console.print("(no minions responded)")
227
+ return
228
+ for minion in sorted(ret):
229
+ _print_one_minion(minion, ret[minion])
230
+
231
+
232
+ # How often to poll jobs.lookup_jid, and how long to keep waiting overall
233
+ # before giving up on minions that never reported. Each poll is a fast,
234
+ # self-contained request, so the proxy/gateway connection cap never bites.
235
+ _POLL_INTERVAL = 3.0
236
+ _POLL_DEADLINE = 1800.0 # 30 minutes (hard backstop)
237
+
238
+ # Once this many seconds pass with no new minion reporting, probe the
239
+ # still-outstanding minions with saltutil.find_job to tell "still running"
240
+ # apart from "down / lost the job" — the latter are dropped so we stop
241
+ # waiting on them.
242
+ #
243
+ # The probe passes BOTH a short publish ``timeout`` and a short
244
+ # ``gather_job_timeout``. The latter matters most: when a call targets an
245
+ # offline minion, the master runs its own internal find_job and waits
246
+ # gather_job_timeout (default ~10s on the master) for a reply that never
247
+ # comes — so without overriding it, flagging an offline minion costs ~10s+
248
+ # no matter how small ``timeout`` is. With both set low the cost drops to a
249
+ # few seconds (verified against this master: offline minion flagged in ~3s).
250
+ # find_job reports whether a minion is *running the job*, so an online minion
251
+ # answers within ``timeout`` and is never wrongly dropped. Instant detection
252
+ # would need presence_events on the master (manage.present/alived are empty).
253
+ _GATHER_TIMEOUT = 5.0
254
+ _FIND_JOB_TIMEOUT = 2.0
255
+ _FIND_JOB_GATHER = 2.0
256
+
257
+
258
+ def _first_return(resp: dict[str, Any]) -> Any:
259
+ """The first element of a salt-api ``return`` list, or ``{}`` if absent."""
260
+ ret = resp.get("return")
261
+ if isinstance(ret, list) and ret:
262
+ return cast("Any", ret[0])
263
+ return {}
264
+
265
+
266
+ def _find_dead(
267
+ call: Callable[..., dict[str, Any]], jid: str, candidates: set[str]
268
+ ) -> set[str]:
269
+ """Return the candidates that are NOT running ``jid`` (down or lost it).
270
+
271
+ Probes only ``candidates`` via the local client + ``saltutil.find_job``
272
+ with a short timeout. A minion actively running the job answers with a
273
+ non-empty dict naming the jid; one that's down never answers, and one
274
+ that's up but no longer running it answers empty — both mean it won't
275
+ return, so it's reported dead. A failed probe reports nobody dead (we'd
276
+ rather wait than wrongly drop a live minion)."""
277
+ if not candidates:
278
+ return set()
279
+ try:
280
+ resp = call(
281
+ "local",
282
+ tgt=sorted(candidates),
283
+ tgt_type="list",
284
+ fun="saltutil.find_job",
285
+ arg=[jid],
286
+ timeout=_FIND_JOB_TIMEOUT,
287
+ gather_job_timeout=_FIND_JOB_GATHER,
288
+ )
289
+ except SaltApiError:
290
+ return set()
291
+ ret = _first_return(resp)
292
+ if not isinstance(ret, dict):
293
+ return set()
294
+ running = cast("dict[str, Any]", ret)
295
+ return {m for m in candidates if not running.get(m)}
296
+
297
+
298
+ def _lookup_returns(raw: Any) -> dict[str, Any]:
299
+ """Pull the ``{minion: state_return}`` map out of a jobs.lookup_jid reply.
300
+
301
+ Over salt-api the runner wraps results in a display envelope —
302
+ ``{"outputter": "highstate", "data": {minion: ...}}`` — unlike the bare
303
+ ``{minion: ...}`` the local client returns. Unwrap ``data`` when present,
304
+ and tolerate either shape (or junk) without raising."""
305
+ if not isinstance(raw, dict):
306
+ return {}
307
+ data = cast("dict[str, Any]", raw)
308
+ inner = data.get("data")
309
+ return cast("dict[str, Any]", inner) if isinstance(inner, dict) else data
310
+
311
+
312
+ def _count_cells(counts: dict[str, int]) -> list[Text]:
313
+ """One right-padded cell per status category, for column alignment in the
314
+ live view. ``ok``/``failed`` always render; the rest blank when zero so
315
+ the column still reserves its width and rows stay aligned."""
316
+ blank = Text("")
317
+ return [
318
+ Text.from_markup(f"[green]{counts['ok']:>2} ok[/]"),
319
+ Text.from_markup(f"[green]{counts['change']:>2} changed[/]")
320
+ if counts["change"]
321
+ else blank,
322
+ Text.from_markup(f"[yellow]{counts['diff']:>2} would-change[/]")
323
+ if counts["diff"]
324
+ else blank,
325
+ Text.from_markup(f"[dim]{counts['skip']:>2} skipped[/]")
326
+ if counts["skip"]
327
+ else blank,
328
+ Text.from_markup(
329
+ f"[red]{counts['fail']:>2} failed[/]"
330
+ if counts["fail"]
331
+ else f"[dim]{counts['fail']:>2} failed[/]"
332
+ ),
333
+ ]
334
+
335
+
336
+ def _live_view(
337
+ targeted: list[str],
338
+ returns: dict[str, Any],
339
+ done: set[str],
340
+ dead: set[str],
341
+ spinner: Spinner,
342
+ ) -> Group:
343
+ """A live checklist: a tick for finished minions (with their per-state
344
+ tally in aligned columns), a spinner for the ones still running, an x for
345
+ the unreachable, under a one-line status header."""
346
+ blanks = [Text("")] * 5 # the five count columns, empty
347
+ grid = Table.grid(padding=(0, 1))
348
+ grid.add_column(no_wrap=True) # marker
349
+ grid.add_column(no_wrap=True) # minion id
350
+ for _ in range(5): # ok / changed / would-change / skipped / failed
351
+ grid.add_column(no_wrap=True, justify="left")
352
+ for minion in targeted:
353
+ if minion in dead:
354
+ grid.add_row(Text("✗", style="red"), Text(minion, style="dim"), *blanks)
355
+ elif minion in done:
356
+ val = returns.get(minion)
357
+ if _is_state_return(val):
358
+ counts, _ = _count_states(cast("dict[str, Any]", val))
359
+ cells = _count_cells(counts)
360
+ else:
361
+ cells = [Text("(no state output)", style="dim"), *blanks[1:]]
362
+ grid.add_row(Text("✓", style="green"), Text(minion), *cells)
363
+ else:
364
+ grid.add_row(spinner, Text(minion, style="dim"), *blanks)
365
+
366
+ pending = len(targeted) - len(done) - len(dead)
367
+ bits = [f"{len(done)}/{len(targeted)} done"]
368
+ if pending:
369
+ bits.append(f"{pending} running")
370
+ if dead:
371
+ bits.append(f"[red]{len(dead)} unreachable[/]")
372
+ header = Text.from_markup(f"[dim]{' '.join(bits)}[/]")
373
+ return Group(header, grid)
374
+
375
+
376
+ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) -> None:
377
+ """Fire a state job async, show a live checklist, then render the results.
378
+
379
+ Submits via the ``local_async`` client (returns a job id at once), then
380
+ polls ``runner jobs.lookup_jid`` until every targeted minion has returned
381
+ or the deadline trips. While polling it shows a live per-minion checklist
382
+ (spinner -> tick). Once the run is done the live view is cleared and the
383
+ coloured per-minion tables print together, followed by a fleet-wide
384
+ summary. ``call(name, **kw)`` invokes the named salt-api client."""
385
+ submit = call("local_async", **payload)
386
+ info: Any = _first_return(submit)
387
+ jid = info.get("jid")
388
+ if not jid:
389
+ # No job id: nothing matched, or salt-api answered with an error body.
390
+ console.print_json(json.dumps(submit))
391
+ return
392
+
393
+ targeted = sorted(info.get("minions") or [])
394
+ if not targeted:
395
+ console.print("(no minions matched the target)")
396
+ return
397
+
398
+ expected = set(targeted) # shrinks as unreachable minions are dropped
399
+ console.print(f"[dim]job {jid} -> {len(targeted)} minion(s)[/]")
400
+ start = time.monotonic()
401
+ returns: dict[str, Any] = {}
402
+ dead: set[str] = set() # probed and confirmed not running the job
403
+ spinner = Spinner("dots", style="cyan")
404
+
405
+ # transient=False keeps the finished checklist on screen above the
406
+ # rendered tables, as a persistent at-a-glance record of the run.
407
+ with Live(console=console, refresh_per_second=12, transient=False) as live:
408
+ prev_done = -1
409
+ last_change = start
410
+ while True:
411
+ # lookup_jid is cumulative: each poll returns every minion that has
412
+ # reported so far, so we just keep the latest snapshot.
413
+ returns = _lookup_returns(
414
+ _first_return(call("runner", fun="jobs.lookup_jid", kwarg={"jid": jid}))
415
+ )
416
+ done = expected & set(returns)
417
+ now = time.monotonic()
418
+ if len(done) != prev_done:
419
+ prev_done, last_change = len(done), now
420
+ live.update(_live_view(targeted, returns, done, dead, spinner))
421
+
422
+ if not expected - done:
423
+ break
424
+
425
+ # Stalled? Ask the stragglers whether they're still running the
426
+ # job; drop the ones that aren't (down or lost it) so we stop
427
+ # waiting on them instead of blocking to the deadline.
428
+ if now - last_change > _GATHER_TIMEOUT:
429
+ gone = _find_dead(call, jid, expected - done)
430
+ if gone:
431
+ dead |= gone
432
+ expected -= gone
433
+ last_change = now # don't re-probe every single poll
434
+ live.update(_live_view(targeted, returns, done, dead, spinner))
435
+ if not expected - done:
436
+ break
437
+
438
+ if now - start > _POLL_DEADLINE:
439
+ break
440
+ time.sleep(_POLL_INTERVAL)
441
+
442
+ # Live view cleared — render the coloured tables, one block per minion.
443
+ _print_state_result({"return": [returns]})
444
+ if dead:
445
+ console.print(
446
+ f"[yellow]no response from: {', '.join(sorted(dead))} "
447
+ f"(down, or no longer running the job)[/]"
448
+ )
449
+ stalled = sorted(expected - set(returns) - dead)
450
+ if stalled:
451
+ console.print(
452
+ f"[yellow]still running at the {int(_POLL_DEADLINE)}s deadline: "
453
+ f"{', '.join(stalled)}[/]"
454
+ )
455
+
456
+ # Fleet-wide summary: totals across all minions + wall-clock elapsed.
457
+ totals, n = _grand_totals(returns)
458
+ if n:
459
+ wall = _fmt_duration((time.monotonic() - start) * 1000.0)
460
+ console.print("[dim]===[/]")
461
+ console.print(f"[bold]{n} minion(s)[/] {_summary_line(totals, wall)}")
462
+
463
+
464
+ def run_state(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
465
+ """The ``salt state`` command, layered over ``local_async`` + ``state.*``.
466
+
467
+ ``call(name, **kw)`` must invoke the named salt-api client and return its
468
+ JSON (cli.py binds it to the configured connection). The job is fired
469
+ async and its results streamed minion-by-minion via the runner. Any
470
+ trailing ``key=value`` args are forwarded as kwargs to the state function
471
+ (e.g. ``test=True``)."""
472
+ pos, kw = split_args(list(getattr(args, "args", None) or []))
473
+ if args.action == "highstate":
474
+ fun, arg = "state.highstate", pos
475
+ elif args.action == "test":
476
+ fun, arg = "state.highstate", pos
477
+ kw["test"] = "True"
478
+ else: # apply <sls>
479
+ fun, arg = "state.apply", [args.sls, *pos]
480
+
481
+ payload: dict[str, Any] = {"tgt": args.target, "fun": fun, "arg": arg}
482
+ if kw:
483
+ payload["kwarg"] = kw
484
+ _stream_state(call, payload)
485
+
486
+
487
+ # --------------------------------------------------------------------------
488
+ # key management
489
+ # --------------------------------------------------------------------------
490
+
491
+
492
+ def _print_key_panels(data: dict[str, Any]) -> None:
493
+ """Render key.list_all as one panel per acceptance status."""
494
+ panels: list[Panel] = []
495
+ for status_key, (label, color) in _KEY_PANELS.items():
496
+ keys: list[str] = data.get(status_key, [])
497
+ body: Any = Text("\n".join(keys)) if keys else Text("(none)", style="dim")
498
+ panels.append(
499
+ Panel(
500
+ body,
501
+ title=f"{label} ({len(keys)})",
502
+ title_align="left",
503
+ border_style=color,
504
+ )
505
+ )
506
+ console.print(Columns(panels, equal=True, expand=False))
507
+
508
+
509
+ def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
510
+ """The ``salt keys`` command, layered over ``wheel key.*``.
511
+
512
+ ``call(fun=..., **kw)`` must invoke the wheel client and return its JSON
513
+ (cli.py binds it to the wheel client)."""
514
+ action: str = args.action
515
+ if action == "list":
516
+ result = call(fun="key.list_all")
517
+ _print_key_panels(result["return"][0]["data"]["return"])
518
+ return
519
+
520
+ fun_map = {
521
+ "accept": "key.accept",
522
+ "accept-all": "key.accept",
523
+ "reject": "key.reject",
524
+ "delete": "key.delete",
525
+ }
526
+ match: str = "*" if action == "accept-all" else args.match
527
+ result = call(fun=fun_map[action], match=match)
528
+ data = result["return"][0]["data"]
529
+ if not data.get("success"):
530
+ sys.exit(f"failed: {data}")
531
+ changed: dict[str, list[str]] = data.get("return", {})
532
+ if not changed:
533
+ console.print("(no keys changed)")
534
+ return
535
+ for status_key, ids in changed.items():
536
+ label = _KEY_PANELS.get(status_key, (status_key, "white"))[0]
537
+ joined = ", ".join(ids) if ids else "[dim](none)[/]"
538
+ console.print(f"{label}: {joined}")
@@ -0,0 +1 @@
1
+ __version__ = "1.3.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -1,255 +0,0 @@
1
- """High-level, human-readable commands for salt-api-cli.
2
-
3
- The low-level commands (``local`` / ``runner`` / ``wheel``) are thin
4
- passthroughs that dump raw salt-api JSON. The commands here are the
5
- opposite: each knows the *shape* of a specific salt workflow and renders it
6
- with :mod:`rich` for a human at a terminal, layered over the low-level
7
- client in :mod:`salt_api_cli.lowlevel`.
8
-
9
- * ``run_state`` — the ``salt state`` command (``highstate`` / ``apply`` /
10
- ``test``). It drives the ``local`` client with a ``state.*`` function and
11
- renders a coloured table of states with a summary, instead of the wall of
12
- JSON the raw ``local`` command would emit.
13
- * ``run_keys`` — the ``salt keys`` command, layered over ``wheel key.*``.
14
- ``keys list`` shows one coloured panel per acceptance status (Accepted /
15
- Pending / Denied / Rejected).
16
-
17
- Each command receives an injected ``call`` callable (bound to the right
18
- client in cli.py), so this module never owns transport details. Colour and
19
- box-drawing are handled by ``rich.Console``, which auto-disables them when
20
- output is piped to a file or pager.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import argparse
26
- import json
27
- import sys
28
- from typing import Any, Callable, cast
29
-
30
- from rich.columns import Columns
31
- from rich.console import Console
32
- from rich.padding import Padding
33
- from rich.panel import Panel
34
- from rich.table import Table
35
- from rich.text import Text
36
-
37
- from salt_api_cli.lowlevel import split_args
38
-
39
- console = Console()
40
-
41
- # (ASCII marker, rich style) for each per-state status. ASCII markers stay
42
- # legible on any console; rich supplies the colour.
43
- _STATUS_STYLE = {
44
- "ok": ("+", "green"), # ran, no changes
45
- "change": ("*", "green"), # ran, made changes
46
- "diff": ("~", "yellow"), # test=True: would change
47
- "fail": ("X", "bold red"), # failed
48
- "skip": (".", "dim"), # requisites unmet, not run
49
- }
50
-
51
- # wheel key.list_all groups minion IDs under these keys; each renders as a
52
- # panel whose border colour signals the acceptance status.
53
- _KEY_PANELS = {
54
- "minions": ("Accepted", "green"),
55
- "minions_pre": ("Pending", "yellow"),
56
- "minions_denied": ("Denied", "red"),
57
- "minions_rejected": ("Rejected", "red"),
58
- }
59
-
60
-
61
- # --------------------------------------------------------------------------
62
- # state rendering
63
- # --------------------------------------------------------------------------
64
-
65
-
66
- def _is_state_return(val: Any) -> bool:
67
- """True if ``val`` is a state return: a non-empty dict whose every value
68
- is itself a dict carrying a ``result`` key (the per-state record shape)."""
69
- if not isinstance(val, dict) or not val:
70
- return False
71
- records = cast("dict[str, Any]", val)
72
- return all(isinstance(v, dict) and "result" in v for v in records.values())
73
-
74
-
75
- def _state_status(state: dict[str, Any]) -> str:
76
- """Classify one state record into an _STATUS_STYLE key."""
77
- if state.get("__state_ran__") is False:
78
- return "skip"
79
- result = state.get("result")
80
- if result is False:
81
- return "fail"
82
- if result is None:
83
- return "diff"
84
- return "change" if state.get("changes") else "ok"
85
-
86
-
87
- def _state_function(key: str) -> str:
88
- """Recover ``module.func`` from a state key like
89
- ``cmd_|-veyon-installed_|-<name>_|-run`` -> ``cmd.run``."""
90
- parts = key.split("_|-")
91
- if len(parts) >= 2 and parts[-1]:
92
- return f"{parts[0]}.{parts[-1]}"
93
- return parts[0]
94
-
95
-
96
- def _short(text: str, limit: int = 100) -> str:
97
- """Collapse whitespace and truncate a comment to one tidy line."""
98
- flat = " ".join(str(text).split())
99
- return flat if len(flat) <= limit else flat[: limit - 3] + "..."
100
-
101
-
102
- def _fmt_duration(ms: float) -> str:
103
- return f"{ms / 1000:.2f}s" if ms >= 1000 else f"{ms:.0f}ms"
104
-
105
-
106
- def _print_state_return(minion: str, states: dict[str, Any]) -> None:
107
- """Render one minion's state run: header, a table of states, summary."""
108
- ordered = sorted(states.items(), key=lambda kv: kv[1].get("__run_num__", 1 << 30))
109
-
110
- table = Table(box=None, show_header=False, pad_edge=False)
111
- table.add_column("marker", no_wrap=True)
112
- table.add_column("function", style="cyan", no_wrap=True)
113
- table.add_column("ref", style="dim", no_wrap=True)
114
- table.add_column("detail", no_wrap=True, overflow="ellipsis")
115
-
116
- counts = {k: 0 for k in _STATUS_STYLE}
117
- total_ms = 0.0
118
- for key, state in ordered:
119
- status = _state_status(state)
120
- counts[status] += 1
121
- try:
122
- total_ms += float(state.get("duration", 0) or 0)
123
- except (TypeError, ValueError):
124
- pass
125
- marker, style = _STATUS_STYLE[status]
126
- ref = f"{state.get('__sls__', '?')}:{state.get('__id__', key)}"
127
- if status == "ok":
128
- detail: str | Text = ""
129
- elif status == "change":
130
- changed = ", ".join(state.get("changes", {})) or "(changes)"
131
- detail = f"changed: {_short(changed)}"
132
- elif status == "fail":
133
- detail = Text(_short(state.get("comment", ""), 240), style="red")
134
- else: # diff / skip
135
- detail = _short(state.get("comment", ""))
136
- table.add_row(Text(marker, style=style), _state_function(key), ref, detail)
137
-
138
- console.print(Text(minion, style="bold"))
139
- console.print(Padding(table, (0, 0, 0, 2)))
140
-
141
- parts = [f"[green]{counts['ok']} ok[/]"]
142
- if counts["change"]:
143
- parts.append(f"[green]{counts['change']} changed[/]")
144
- if counts["diff"]:
145
- parts.append(f"[yellow]{counts['diff']} would-change[/]")
146
- if counts["skip"]:
147
- parts.append(f"[dim]{counts['skip']} skipped[/]")
148
- parts.append(
149
- f"[red]{counts['fail']} failed[/]"
150
- if counts["fail"]
151
- else f"{counts['fail']} failed"
152
- )
153
- console.print(" [dim]---[/]")
154
- console.print(f" {' '.join(parts)} [dim]took {_fmt_duration(total_ms)}[/]")
155
-
156
-
157
- def _print_state_result(result: dict[str, Any]) -> None:
158
- """Render a state return from the local client, one block per minion.
159
-
160
- Falls back to indented JSON for anything that isn't a state return — e.g.
161
- a render/compile error, where salt answers with a list of message lines."""
162
- ret_list: Any = result.get("return")
163
- if not ret_list:
164
- console.print_json(json.dumps(result))
165
- return
166
- ret: dict[str, Any] = ret_list[0]
167
- if not ret:
168
- console.print("(no minions responded)")
169
- return
170
- for minion in sorted(ret):
171
- val = ret[minion]
172
- if _is_state_return(val):
173
- _print_state_return(minion, val)
174
- continue
175
- console.print(Text(minion, style="bold"))
176
- if isinstance(val, list):
177
- for item in cast("list[Any]", val):
178
- console.print(Padding(Text(str(item)), (0, 0, 0, 2)))
179
- else:
180
- console.print(Padding(json.dumps(val, indent=2), (0, 0, 0, 2)))
181
-
182
-
183
- def run_state(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
184
- """The ``salt state`` command, layered over the local client + ``state.*``.
185
-
186
- ``call(tgt=..., fun=..., ...)`` must invoke the local client and return
187
- its JSON (cli.py binds it to the local client). Any trailing ``key=value``
188
- args are forwarded as kwargs to the state function (e.g. ``test=True``)."""
189
- pos, kw = split_args(list(getattr(args, "args", None) or []))
190
- if args.action == "highstate":
191
- fun, arg = "state.highstate", pos
192
- elif args.action == "test":
193
- fun, arg = "state.highstate", pos
194
- kw["test"] = "True"
195
- else: # apply <sls>
196
- fun, arg = "state.apply", [args.sls, *pos]
197
-
198
- payload: dict[str, Any] = {"tgt": args.target, "fun": fun, "arg": arg}
199
- if kw:
200
- payload["kwarg"] = kw
201
- _print_state_result(call(**payload))
202
-
203
-
204
- # --------------------------------------------------------------------------
205
- # key management
206
- # --------------------------------------------------------------------------
207
-
208
-
209
- def _print_key_panels(data: dict[str, Any]) -> None:
210
- """Render key.list_all as one panel per acceptance status."""
211
- panels: list[Panel] = []
212
- for status_key, (label, color) in _KEY_PANELS.items():
213
- keys: list[str] = data.get(status_key, [])
214
- body: Any = Text("\n".join(keys)) if keys else Text("(none)", style="dim")
215
- panels.append(
216
- Panel(
217
- body,
218
- title=f"{label} ({len(keys)})",
219
- title_align="left",
220
- border_style=color,
221
- )
222
- )
223
- console.print(Columns(panels, equal=True, expand=False))
224
-
225
-
226
- def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
227
- """The ``salt keys`` command, layered over ``wheel key.*``.
228
-
229
- ``call(fun=..., **kw)`` must invoke the wheel client and return its JSON
230
- (cli.py binds it to the wheel client)."""
231
- action: str = args.action
232
- if action == "list":
233
- result = call(fun="key.list_all")
234
- _print_key_panels(result["return"][0]["data"]["return"])
235
- return
236
-
237
- fun_map = {
238
- "accept": "key.accept",
239
- "accept-all": "key.accept",
240
- "reject": "key.reject",
241
- "delete": "key.delete",
242
- }
243
- match: str = "*" if action == "accept-all" else args.match
244
- result = call(fun=fun_map[action], match=match)
245
- data = result["return"][0]["data"]
246
- if not data.get("success"):
247
- sys.exit(f"failed: {data}")
248
- changed: dict[str, list[str]] = data.get("return", {})
249
- if not changed:
250
- console.print("(no keys changed)")
251
- return
252
- for status_key, ids in changed.items():
253
- label = _KEY_PANELS.get(status_key, (status_key, "white"))[0]
254
- joined = ", ".join(ids) if ids else "[dim](none)[/]"
255
- console.print(f"{label}: {joined}")
@@ -1 +0,0 @@
1
- __version__ = "1.2.0"
File without changes
File without changes
File without changes