salt-api-cli 1.3.0__tar.gz → 1.4.1__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.3.0
3
+ Version: 1.4.1
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -33,7 +33,7 @@ Commands come in two layers:
33
33
 
34
34
  - **Low-level** (`local`, `runner`, `wheel`) map directly to the salt-api
35
35
  clients and print **raw JSON**.
36
- - **High-level** (`state`, `keys`) wrap those clients and render
36
+ - **High-level** (`cmd`, `state`, `keys`) wrap those clients and render
37
37
  **readable, colorized output** with `rich`.
38
38
 
39
39
  ## Installation
@@ -76,9 +76,9 @@ verbatim as indented JSON.
76
76
 
77
77
  ```
78
78
  # Local client — fan out to minions
79
- salt local '*' test.ping
80
- salt local 'bml*' cmd.run 'whoami'
81
- salt local 'bml1' cmd.run 'Get-Date' shell=powershell
79
+ salt local "*" test.ping
80
+ salt local "bml*" cmd.run whoami
81
+ salt local "bml1" cmd.run "Get-Date" shell=powershell
82
82
 
83
83
  # Runner client (master-side: manage.status, jobs.list_jobs, ...)
84
84
  salt runner manage.status
@@ -93,20 +93,27 @@ salt wheel key.list_all
93
93
  These wrap the low-level clients and render their output with `rich`.
94
94
 
95
95
  ```
96
+ # Run a shell command — a live per-minion checklist while it runs, then
97
+ # one block per minion (exit code, stdout, stderr) and an ok/failed summary.
98
+ # Fired async (local_async + cmd.run_all) and polled via the runner, like
99
+ # `state`, so a slow or wide command never holds one long connection open.
100
+ salt cmd "bml*" hostname
101
+ salt cmd "bml1" "Get-Date" shell=powershell
102
+
96
103
  # State runs — a colored table of states, one row each, with a summary.
97
104
  # Driven by the local client + state.* functions.
98
- salt state highstate 'bml1' # apply the highstate
99
- salt state test 'bml1' # dry-run the highstate (forces test=True)
100
- salt state apply 'bml1' veyon # apply specific sls module(s)
101
- salt state apply 'bml1' veyon.ldap test=True
105
+ salt state highstate "bml1" # apply the highstate
106
+ salt state test "bml1" # dry-run the highstate (forces test=True)
107
+ salt state apply "bml1" veyon # apply specific sls module(s)
108
+ salt state apply "bml1" veyon.ldap test=True
102
109
 
103
110
  # Key management — wraps the wheel client's key.* functions.
104
111
  # `keys list` shows one colored panel per status (Accepted/Pending/Denied/Rejected).
105
112
  salt keys list
106
- salt keys accept <id-or-glob>
113
+ salt keys accept "<id-or-glob>"
107
114
  salt keys accept-all
108
- salt keys reject <id-or-glob>
109
- salt keys delete <id-or-glob>
115
+ salt keys reject "<id-or-glob>"
116
+ salt keys delete "<id-or-glob>"
110
117
  ```
111
118
 
112
119
  Color and panels appear when writing to a terminal; output is plain when
@@ -17,7 +17,7 @@ Commands come in two layers:
17
17
 
18
18
  - **Low-level** (`local`, `runner`, `wheel`) map directly to the salt-api
19
19
  clients and print **raw JSON**.
20
- - **High-level** (`state`, `keys`) wrap those clients and render
20
+ - **High-level** (`cmd`, `state`, `keys`) wrap those clients and render
21
21
  **readable, colorized output** with `rich`.
22
22
 
23
23
  ## Installation
@@ -60,9 +60,9 @@ verbatim as indented JSON.
60
60
 
61
61
  ```
62
62
  # Local client — fan out to minions
63
- salt local '*' test.ping
64
- salt local 'bml*' cmd.run 'whoami'
65
- salt local 'bml1' cmd.run 'Get-Date' shell=powershell
63
+ salt local "*" test.ping
64
+ salt local "bml*" cmd.run whoami
65
+ salt local "bml1" cmd.run "Get-Date" shell=powershell
66
66
 
67
67
  # Runner client (master-side: manage.status, jobs.list_jobs, ...)
68
68
  salt runner manage.status
@@ -77,20 +77,27 @@ salt wheel key.list_all
77
77
  These wrap the low-level clients and render their output with `rich`.
78
78
 
79
79
  ```
80
+ # Run a shell command — a live per-minion checklist while it runs, then
81
+ # one block per minion (exit code, stdout, stderr) and an ok/failed summary.
82
+ # Fired async (local_async + cmd.run_all) and polled via the runner, like
83
+ # `state`, so a slow or wide command never holds one long connection open.
84
+ salt cmd "bml*" hostname
85
+ salt cmd "bml1" "Get-Date" shell=powershell
86
+
80
87
  # State runs — a colored table of states, one row each, with a summary.
81
88
  # Driven by the local client + state.* functions.
82
- salt state highstate 'bml1' # apply the highstate
83
- salt state test 'bml1' # dry-run the highstate (forces test=True)
84
- salt state apply 'bml1' veyon # apply specific sls module(s)
85
- salt state apply 'bml1' veyon.ldap test=True
89
+ salt state highstate "bml1" # apply the highstate
90
+ salt state test "bml1" # dry-run the highstate (forces test=True)
91
+ salt state apply "bml1" veyon # apply specific sls module(s)
92
+ salt state apply "bml1" veyon.ldap test=True
86
93
 
87
94
  # Key management — wraps the wheel client's key.* functions.
88
95
  # `keys list` shows one colored panel per status (Accepted/Pending/Denied/Rejected).
89
96
  salt keys list
90
- salt keys accept <id-or-glob>
97
+ salt keys accept "<id-or-glob>"
91
98
  salt keys accept-all
92
- salt keys reject <id-or-glob>
93
- salt keys delete <id-or-glob>
99
+ salt keys reject "<id-or-glob>"
100
+ salt keys delete "<id-or-glob>"
94
101
  ```
95
102
 
96
103
  Color and panels appear when writing to a terminal; output is plain when
@@ -75,24 +75,37 @@ def _run_keys(cfg: Config, args: argparse.Namespace) -> None:
75
75
  highlevel.run_keys(args, wheel)
76
76
 
77
77
 
78
+ def _run_cmd(cfg: Config, args: argparse.Namespace) -> None:
79
+ def client(name: str, **kw: Any) -> dict[str, Any]:
80
+ return call(cfg, name, **kw)
81
+
82
+ highlevel.run_cmd(args, client)
83
+
84
+
78
85
  def _build_parser() -> argparse.ArgumentParser:
79
86
  parser = argparse.ArgumentParser(
80
87
  prog="salt",
81
88
  description="Thin Python CLI for salt-api.",
82
89
  formatter_class=argparse.RawDescriptionHelpFormatter,
83
90
  epilog=(
91
+ 'quote glob targets with double quotes ("bml*") - they work in\n'
92
+ "bash, PowerShell, and cmd.exe alike (single quotes are not quotes\n"
93
+ "in cmd.exe, so 'bml*' there reaches salt with the quotes attached).\n"
94
+ "\n"
84
95
  "low-level (raw JSON):\n"
85
- " salt local '*' test.ping\n"
86
- " salt local 'bml*' cmd.run 'whoami'\n"
87
- " salt local 'bml1' cmd.run 'Get-Date' shell=powershell\n"
96
+ ' salt local "*" test.ping\n'
97
+ ' salt local "bml*" cmd.run whoami\n'
98
+ ' salt local "bml1" cmd.run "Get-Date" shell=powershell\n'
88
99
  " salt runner manage.status\n"
89
100
  " salt wheel key.list_all\n"
90
101
  "high-level (readable):\n"
91
- " salt state highstate 'bml1'\n"
92
- " salt state test 'bml1' # dry-run highstate (test=True)\n"
93
- " salt state apply 'bml1' veyon\n"
102
+ ' salt cmd "bml*" hostname\n'
103
+ ' salt cmd "bml1" "Get-Date" shell=powershell\n'
104
+ ' salt state highstate "bml1"\n'
105
+ ' salt state test "bml1" # dry-run highstate (test=True)\n'
106
+ ' salt state apply "bml1" veyon\n'
94
107
  " salt keys list\n"
95
- " salt keys accept '<id-or-glob>'\n"
108
+ ' salt keys accept "<id-or-glob>"\n'
96
109
  " salt keys accept-all\n"
97
110
  ),
98
111
  )
@@ -133,6 +146,15 @@ def _build_parser() -> argparse.ArgumentParser:
133
146
  p_wheel.add_argument("function")
134
147
  p_wheel.add_argument("args", nargs=argparse.REMAINDER)
135
148
 
149
+ p_cmd = sub.add_parser("cmd", help="run a shell command with readable output")
150
+ p_cmd.add_argument("target", help="minion target (id or glob)")
151
+ p_cmd.add_argument("cmdline", metavar="command", help="shell command to run")
152
+ p_cmd.add_argument(
153
+ "args",
154
+ nargs=argparse.REMAINDER,
155
+ help="key=value args, e.g. shell=powershell",
156
+ )
157
+
136
158
  p_state = sub.add_parser("state", help="apply states with readable output")
137
159
  state_sub = p_state.add_subparsers(dest="action", required=True)
138
160
  p_highstate = state_sub.add_parser("highstate", help="apply the highstate")
@@ -192,6 +214,8 @@ def main() -> None:
192
214
  _run_client(cfg, "runner", args)
193
215
  elif args.command == "wheel":
194
216
  _run_client(cfg, "wheel", args)
217
+ elif args.command == "cmd":
218
+ _run_cmd(cfg, args)
195
219
  elif args.command == "state":
196
220
  _run_state(cfg, args)
197
221
  elif args.command == "keys":
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
 
28
28
  import argparse
29
29
  import json
30
+ import re
30
31
  import sys
31
32
  import time
32
33
  from typing import Any, Callable, cast
@@ -168,12 +169,7 @@ def _print_state_return(minion: str, states: dict[str, Any]) -> None:
168
169
  """Render one minion's state run: header, a table of states, summary."""
169
170
  ordered = sorted(states.items(), key=lambda kv: kv[1].get("__run_num__", 1 << 30))
170
171
 
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
-
172
+ rows: list[tuple[Text, str, str, str | Text]] = []
177
173
  for key, state in ordered:
178
174
  status = _state_status(state)
179
175
  marker, style = _STATUS_STYLE[status]
@@ -187,7 +183,28 @@ def _print_state_return(minion: str, states: dict[str, Any]) -> None:
187
183
  detail = Text(_short(state.get("comment", ""), 240), style="red")
188
184
  else: # diff / skip
189
185
  detail = _short(state.get("comment", ""))
190
- table.add_row(Text(marker, style=style), _state_function(key), ref, detail)
186
+ rows.append((Text(marker, style=style), _state_function(key), ref, detail))
187
+
188
+ # Pin the detail column to whatever width is left so rich shrinks *it*
189
+ # (ellipsis) rather than collapsing the short marker/function/ref columns
190
+ # to nothing on a narrow terminal. Width budget: 2-space left Padding +
191
+ # 1-char marker + the natural function/ref widths + three 2-space column
192
+ # gaps (pad_edge=False). Floor at 20 so detail never vanishes outright.
193
+ fn_w = max((len(fn) for _, fn, _, _ in rows), default=8)
194
+ ref_w = max((len(ref) for _, _, ref, _ in rows), default=8)
195
+ nat_w = max(
196
+ (len(d.plain if isinstance(d, Text) else d) for _, _, _, d in rows), default=0
197
+ )
198
+ detail_w = min(nat_w, max(20, console.width - 2 - 1 - fn_w - ref_w - 3 * 2))
199
+
200
+ table = Table(box=None, show_header=False, pad_edge=False)
201
+ table.add_column("marker", no_wrap=True)
202
+ table.add_column("function", style="cyan", no_wrap=True)
203
+ table.add_column("ref", style="dim", no_wrap=True)
204
+ table.add_column("detail", no_wrap=True, overflow="ellipsis", width=detail_w)
205
+
206
+ for row in rows:
207
+ table.add_row(*row)
191
208
 
192
209
  counts, total_ms = _count_states(states)
193
210
  console.print(Text(minion, style="bold"))
@@ -333,33 +350,42 @@ def _count_cells(counts: dict[str, int]) -> list[Text]:
333
350
  ]
334
351
 
335
352
 
353
+ def _state_cells(val: Any) -> list[Text]:
354
+ """The five live-view columns for a finished minion's state return: its
355
+ per-status tally, or a placeholder (plus blanks) for a non-state reply."""
356
+ if _is_state_return(val):
357
+ counts, _ = _count_states(cast("dict[str, Any]", val))
358
+ return _count_cells(counts)
359
+ return [Text("(no state output)", style="dim"), *[Text("")] * 4]
360
+
361
+
336
362
  def _live_view(
337
363
  targeted: list[str],
338
364
  returns: dict[str, Any],
339
365
  done: set[str],
340
366
  dead: set[str],
341
367
  spinner: Spinner,
368
+ *,
369
+ n_cells: int,
370
+ cells_for: Callable[[Any], list[Text]],
342
371
  ) -> 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
372
+ """A live checklist: a tick for finished minions (with ``cells_for`` of
373
+ their reply in aligned columns), a spinner for the ones still running, an x
374
+ for the unreachable, under a one-line status header. ``n_cells`` is how
375
+ many trailing columns ``cells_for`` produces (so blank rows stay aligned)."""
376
+ blanks = [Text("")] * n_cells
347
377
  grid = Table.grid(padding=(0, 1))
348
378
  grid.add_column(no_wrap=True) # marker
349
379
  grid.add_column(no_wrap=True) # minion id
350
- for _ in range(5): # ok / changed / would-change / skipped / failed
380
+ for _ in range(n_cells): # per-command trailing columns
351
381
  grid.add_column(no_wrap=True, justify="left")
352
382
  for minion in targeted:
353
383
  if minion in dead:
354
- grid.add_row(Text("", style="red"), Text(minion, style="dim"), *blanks)
384
+ grid.add_row(Text("X", style="red"), Text(minion, style="dim"), *blanks)
355
385
  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)
386
+ grid.add_row(
387
+ Text("+", style="green"), Text(minion), *cells_for(returns.get(minion))
388
+ )
363
389
  else:
364
390
  grid.add_row(spinner, Text(minion, style="dim"), *blanks)
365
391
 
@@ -373,27 +399,41 @@ def _live_view(
373
399
  return Group(header, grid)
374
400
 
375
401
 
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."""
402
+ def _stream_job(
403
+ call: Callable[..., dict[str, Any]],
404
+ payload: dict[str, Any],
405
+ *,
406
+ n_cells: int,
407
+ cells_for: Callable[[Any], list[Text]],
408
+ ) -> tuple[dict[str, Any], set[str], set[str], float] | None:
409
+ """Fire a job async, show a live checklist, and return its raw results.
410
+
411
+ Submits ``payload`` via the ``local_async`` client (returns a job id at
412
+ once), then polls ``runner jobs.lookup_jid`` until every targeted minion
413
+ has returned or the deadline trips. While polling it shows a live
414
+ per-minion checklist (spinner -> tick), whose trailing columns come from
415
+ ``cells_for(value)`` (``n_cells`` of them). Once the run is done the live
416
+ view is cleared and this returns ``(returns, dead, expected, start)`` for
417
+ the caller to render — or ``None`` if no job started (already reported).
418
+ ``call(name, **kw)`` invokes the named salt-api client."""
385
419
  submit = call("local_async", **payload)
386
420
  info: Any = _first_return(submit)
387
421
  jid = info.get("jid")
388
422
  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
423
+ # No job id: either nothing matched (salt-api hands back an empty
424
+ # body, e.g. {"return": [{}]}) or it answered with an error body. An
425
+ # empty info means no minions matched — say so plainly; reserve the
426
+ # raw JSON dump for an actual error worth showing verbatim.
427
+ if not info:
428
+ console.print("(no minions matched the target)")
429
+ else:
430
+ console.print_json(json.dumps(submit))
431
+ return None
392
432
 
393
- targeted = sorted(info.get("minions") or [])
433
+ targeted = sorted(info.get("minions") or [], key=_natural_key)
394
434
  if not targeted:
395
435
  console.print("(no minions matched the target)")
396
- return
436
+ return None
397
437
 
398
438
  expected = set(targeted) # shrinks as unreachable minions are dropped
399
439
  console.print(f"[dim]job {jid} -> {len(targeted)} minion(s)[/]")
@@ -402,6 +442,12 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
402
442
  dead: set[str] = set() # probed and confirmed not running the job
403
443
  spinner = Spinner("dots", style="cyan")
404
444
 
445
+ def view() -> Group:
446
+ done = expected & set(returns)
447
+ return _live_view(
448
+ targeted, returns, done, dead, spinner, n_cells=n_cells, cells_for=cells_for
449
+ )
450
+
405
451
  # transient=False keeps the finished checklist on screen above the
406
452
  # rendered tables, as a persistent at-a-glance record of the run.
407
453
  with Live(console=console, refresh_per_second=12, transient=False) as live:
@@ -417,7 +463,7 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
417
463
  now = time.monotonic()
418
464
  if len(done) != prev_done:
419
465
  prev_done, last_change = len(done), now
420
- live.update(_live_view(targeted, returns, done, dead, spinner))
466
+ live.update(view())
421
467
 
422
468
  if not expected - done:
423
469
  break
@@ -431,7 +477,7 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
431
477
  dead |= gone
432
478
  expected -= gone
433
479
  last_change = now # don't re-probe every single poll
434
- live.update(_live_view(targeted, returns, done, dead, spinner))
480
+ live.update(view())
435
481
  if not expected - done:
436
482
  break
437
483
 
@@ -439,20 +485,36 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
439
485
  break
440
486
  time.sleep(_POLL_INTERVAL)
441
487
 
442
- # Live view cleared — render the coloured tables, one block per minion.
443
- _print_state_result({"return": [returns]})
488
+ return returns, dead, expected, start
489
+
490
+
491
+ def _print_stragglers(dead: set[str], stalled: list[str]) -> None:
492
+ """The shared trailer for a streamed job: who never answered and who was
493
+ still running when the deadline tripped."""
444
494
  if dead:
445
495
  console.print(
446
- f"[yellow]no response from: {', '.join(sorted(dead))} "
496
+ f"[yellow]no response from: {', '.join(sorted(dead, key=_natural_key))} "
447
497
  f"(down, or no longer running the job)[/]"
448
498
  )
449
- stalled = sorted(expected - set(returns) - dead)
450
499
  if stalled:
451
500
  console.print(
452
501
  f"[yellow]still running at the {int(_POLL_DEADLINE)}s deadline: "
453
502
  f"{', '.join(stalled)}[/]"
454
503
  )
455
504
 
505
+
506
+ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) -> None:
507
+ """Stream a state job, then render the coloured per-minion tables and a
508
+ fleet-wide summary."""
509
+ result = _stream_job(call, payload, n_cells=5, cells_for=_state_cells)
510
+ if result is None:
511
+ return
512
+ returns, dead, expected, start = result
513
+
514
+ # Live view cleared — render the coloured tables, one block per minion.
515
+ _print_state_result({"return": [returns]})
516
+ _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
517
+
456
518
  # Fleet-wide summary: totals across all minions + wall-clock elapsed.
457
519
  totals, n = _grand_totals(returns)
458
520
  if n:
@@ -489,13 +551,22 @@ def run_state(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) ->
489
551
  # --------------------------------------------------------------------------
490
552
 
491
553
 
554
+ def _natural_key(name: str) -> list[object]:
555
+ """Sort key that orders embedded numbers numerically (bml2 before bml10)."""
556
+ return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", name)]
557
+
558
+
492
559
  def _print_key_panels(data: dict[str, Any]) -> None:
493
- """Render key.list_all as one panel per acceptance status."""
494
- panels: list[Panel] = []
560
+ """Render key.list_all as one stacked panel per acceptance status, the
561
+ IDs flowed into aligned columns inside each panel."""
495
562
  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(
563
+ keys: list[str] = sorted(data.get(status_key, []), key=_natural_key)
564
+ body: Any = (
565
+ Columns([Text(k) for k in keys], padding=(0, 2))
566
+ if keys
567
+ else Text("(none)", style="dim")
568
+ )
569
+ console.print(
499
570
  Panel(
500
571
  body,
501
572
  title=f"{label} ({len(keys)})",
@@ -503,7 +574,6 @@ def _print_key_panels(data: dict[str, Any]) -> None:
503
574
  border_style=color,
504
575
  )
505
576
  )
506
- console.print(Columns(panels, equal=True, expand=False))
507
577
 
508
578
 
509
579
  def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
@@ -536,3 +606,113 @@ def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> N
536
606
  label = _KEY_PANELS.get(status_key, (status_key, "white"))[0]
537
607
  joined = ", ".join(ids) if ids else "[dim](none)[/]"
538
608
  console.print(f"{label}: {joined}")
609
+
610
+
611
+ # --------------------------------------------------------------------------
612
+ # command execution
613
+ # --------------------------------------------------------------------------
614
+
615
+
616
+ def _print_cmd_one(minion: str, val: Any) -> None:
617
+ """Render one minion's ``cmd.run_all`` reply: a bold id with its exit code
618
+ (green for 0, red otherwise), then stdout and any stderr indented beneath.
619
+
620
+ Falls back to printing the raw value for any non-dict shape — e.g. a
621
+ minion that errored before the command ran, where salt returns a string."""
622
+ if not isinstance(val, dict):
623
+ console.print(Text(minion, style="bold"))
624
+ console.print(Padding(Text(str(val)), (0, 0, 0, 2)))
625
+ return
626
+
627
+ record = cast("dict[str, Any]", val)
628
+ retcode = record.get("retcode")
629
+ header = Text(minion, style="bold")
630
+ if retcode == 0:
631
+ header.append(" exit 0", style="green")
632
+ elif retcode is not None:
633
+ header.append(f" exit {retcode}", style="red")
634
+ console.print(header)
635
+
636
+ stdout = str(record.get("stdout", "")).rstrip()
637
+ stderr = str(record.get("stderr", "")).rstrip()
638
+ if stdout:
639
+ console.print(Padding(Text(stdout), (0, 0, 0, 2)))
640
+ if stderr:
641
+ console.print(Padding(Text("stderr:", style="red"), (0, 0, 0, 2)))
642
+ console.print(Padding(Text(stderr, style="red"), (0, 0, 0, 4)))
643
+ if not stdout and not stderr:
644
+ console.print(Padding(Text("(no output)", style="dim"), (0, 0, 0, 2)))
645
+
646
+
647
+ def _print_cmd_result(resp: dict[str, Any]) -> None:
648
+ """Render a ``cmd.run_all`` reply, one block per minion (naturally sorted)."""
649
+ ret = _first_return(resp)
650
+ if not isinstance(ret, dict) or not ret:
651
+ console.print("(no minions responded)")
652
+ return
653
+ results = cast("dict[str, Any]", ret)
654
+ for minion in sorted(results, key=_natural_key):
655
+ _print_cmd_one(minion, results[minion])
656
+
657
+
658
+ def _cmd_cells(val: Any) -> list[Text]:
659
+ """The single live-view column for a finished minion's ``cmd.run_all``
660
+ reply: its exit code, green for 0 and red otherwise."""
661
+ if isinstance(val, dict):
662
+ retcode = cast("dict[str, Any]", val).get("retcode")
663
+ if retcode == 0:
664
+ return [Text("exit 0", style="green")]
665
+ if retcode is not None:
666
+ return [Text(f"exit {retcode}", style="red")]
667
+ return [Text("(no output)", style="dim")]
668
+
669
+
670
+ def _stream_cmd(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) -> None:
671
+ """Stream a ``cmd.run_all`` job, then render each minion's output block and
672
+ a fleet-wide ok/failed summary."""
673
+ result = _stream_job(call, payload, n_cells=1, cells_for=_cmd_cells)
674
+ if result is None:
675
+ return
676
+ returns, dead, expected, start = result
677
+
678
+ _print_cmd_result({"return": [returns]})
679
+ _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
680
+
681
+ n = len(returns)
682
+ if n:
683
+ ok = sum(
684
+ 1
685
+ for v in returns.values()
686
+ if isinstance(v, dict) and cast("dict[str, Any]", v).get("retcode") == 0
687
+ )
688
+ fail = n - ok
689
+ wall = _fmt_duration((time.monotonic() - start) * 1000.0)
690
+ tally = f"[green]{ok} ok[/] " + (
691
+ f"[red]{fail} failed[/]" if fail else f"{fail} failed"
692
+ )
693
+ console.print("[dim]===[/]")
694
+ console.print(f"[bold]{n} minion(s)[/] {tally} [dim]took {wall}[/]")
695
+
696
+
697
+ def run_cmd(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
698
+ """The ``salt cmd`` command, layered over ``local_async`` + ``cmd.run_all``.
699
+
700
+ Runs a shell command on the targeted minions and streams the results: a
701
+ live per-minion checklist (spinner -> exit code) while the job runs, then a
702
+ readable block per minion (exit code, stdout, stderr) and an ok/failed
703
+ summary — instead of the raw JSON the low-level ``local`` command emits.
704
+ Like ``state``, it fires the job async and polls the runner so a slow or
705
+ wide command never holds one long connection open against the gateway cap.
706
+ Trailing ``key=value`` args are forwarded as kwargs to ``cmd.run_all``
707
+ (e.g. ``shell=powershell``, ``cwd=...``, ``runas=...``). ``call(name,
708
+ **kw)`` invokes the named salt-api client (cli.py binds it to the
709
+ configured connection)."""
710
+ pos, kw = split_args(list(getattr(args, "args", None) or []))
711
+ payload: dict[str, Any] = {
712
+ "tgt": args.target,
713
+ "fun": "cmd.run_all",
714
+ "arg": [args.cmdline, *pos],
715
+ }
716
+ if kw:
717
+ payload["kwarg"] = kw
718
+ _stream_cmd(call, payload)
@@ -0,0 +1 @@
1
+ __version__ = "1.4.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.3.0
3
+ Version: 1.4.1
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -33,7 +33,7 @@ Commands come in two layers:
33
33
 
34
34
  - **Low-level** (`local`, `runner`, `wheel`) map directly to the salt-api
35
35
  clients and print **raw JSON**.
36
- - **High-level** (`state`, `keys`) wrap those clients and render
36
+ - **High-level** (`cmd`, `state`, `keys`) wrap those clients and render
37
37
  **readable, colorized output** with `rich`.
38
38
 
39
39
  ## Installation
@@ -76,9 +76,9 @@ verbatim as indented JSON.
76
76
 
77
77
  ```
78
78
  # Local client — fan out to minions
79
- salt local '*' test.ping
80
- salt local 'bml*' cmd.run 'whoami'
81
- salt local 'bml1' cmd.run 'Get-Date' shell=powershell
79
+ salt local "*" test.ping
80
+ salt local "bml*" cmd.run whoami
81
+ salt local "bml1" cmd.run "Get-Date" shell=powershell
82
82
 
83
83
  # Runner client (master-side: manage.status, jobs.list_jobs, ...)
84
84
  salt runner manage.status
@@ -93,20 +93,27 @@ salt wheel key.list_all
93
93
  These wrap the low-level clients and render their output with `rich`.
94
94
 
95
95
  ```
96
+ # Run a shell command — a live per-minion checklist while it runs, then
97
+ # one block per minion (exit code, stdout, stderr) and an ok/failed summary.
98
+ # Fired async (local_async + cmd.run_all) and polled via the runner, like
99
+ # `state`, so a slow or wide command never holds one long connection open.
100
+ salt cmd "bml*" hostname
101
+ salt cmd "bml1" "Get-Date" shell=powershell
102
+
96
103
  # State runs — a colored table of states, one row each, with a summary.
97
104
  # Driven by the local client + state.* functions.
98
- salt state highstate 'bml1' # apply the highstate
99
- salt state test 'bml1' # dry-run the highstate (forces test=True)
100
- salt state apply 'bml1' veyon # apply specific sls module(s)
101
- salt state apply 'bml1' veyon.ldap test=True
105
+ salt state highstate "bml1" # apply the highstate
106
+ salt state test "bml1" # dry-run the highstate (forces test=True)
107
+ salt state apply "bml1" veyon # apply specific sls module(s)
108
+ salt state apply "bml1" veyon.ldap test=True
102
109
 
103
110
  # Key management — wraps the wheel client's key.* functions.
104
111
  # `keys list` shows one colored panel per status (Accepted/Pending/Denied/Rejected).
105
112
  salt keys list
106
- salt keys accept <id-or-glob>
113
+ salt keys accept "<id-or-glob>"
107
114
  salt keys accept-all
108
- salt keys reject <id-or-glob>
109
- salt keys delete <id-or-glob>
115
+ salt keys reject "<id-or-glob>"
116
+ salt keys delete "<id-or-glob>"
110
117
  ```
111
118
 
112
119
  Color and panels appear when writing to a terminal; output is plain when
@@ -1 +0,0 @@
1
- __version__ = "1.3.0"
File without changes
File without changes