salt-api-cli 1.3.0__tar.gz → 1.4.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.3.0
3
+ Version: 1.4.0
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
@@ -333,33 +334,42 @@ def _count_cells(counts: dict[str, int]) -> list[Text]:
333
334
  ]
334
335
 
335
336
 
337
+ def _state_cells(val: Any) -> list[Text]:
338
+ """The five live-view columns for a finished minion's state return: its
339
+ per-status tally, or a placeholder (plus blanks) for a non-state reply."""
340
+ if _is_state_return(val):
341
+ counts, _ = _count_states(cast("dict[str, Any]", val))
342
+ return _count_cells(counts)
343
+ return [Text("(no state output)", style="dim"), *[Text("")] * 4]
344
+
345
+
336
346
  def _live_view(
337
347
  targeted: list[str],
338
348
  returns: dict[str, Any],
339
349
  done: set[str],
340
350
  dead: set[str],
341
351
  spinner: Spinner,
352
+ *,
353
+ n_cells: int,
354
+ cells_for: Callable[[Any], list[Text]],
342
355
  ) -> 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
356
+ """A live checklist: a tick for finished minions (with ``cells_for`` of
357
+ their reply in aligned columns), a spinner for the ones still running, an x
358
+ for the unreachable, under a one-line status header. ``n_cells`` is how
359
+ many trailing columns ``cells_for`` produces (so blank rows stay aligned)."""
360
+ blanks = [Text("")] * n_cells
347
361
  grid = Table.grid(padding=(0, 1))
348
362
  grid.add_column(no_wrap=True) # marker
349
363
  grid.add_column(no_wrap=True) # minion id
350
- for _ in range(5): # ok / changed / would-change / skipped / failed
364
+ for _ in range(n_cells): # per-command trailing columns
351
365
  grid.add_column(no_wrap=True, justify="left")
352
366
  for minion in targeted:
353
367
  if minion in dead:
354
- grid.add_row(Text("", style="red"), Text(minion, style="dim"), *blanks)
368
+ grid.add_row(Text("X", style="red"), Text(minion, style="dim"), *blanks)
355
369
  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)
370
+ grid.add_row(
371
+ Text("+", style="green"), Text(minion), *cells_for(returns.get(minion))
372
+ )
363
373
  else:
364
374
  grid.add_row(spinner, Text(minion, style="dim"), *blanks)
365
375
 
@@ -373,27 +383,35 @@ def _live_view(
373
383
  return Group(header, grid)
374
384
 
375
385
 
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."""
386
+ def _stream_job(
387
+ call: Callable[..., dict[str, Any]],
388
+ payload: dict[str, Any],
389
+ *,
390
+ n_cells: int,
391
+ cells_for: Callable[[Any], list[Text]],
392
+ ) -> tuple[dict[str, Any], set[str], set[str], float] | None:
393
+ """Fire a job async, show a live checklist, and return its raw results.
394
+
395
+ Submits ``payload`` via the ``local_async`` client (returns a job id at
396
+ once), then polls ``runner jobs.lookup_jid`` until every targeted minion
397
+ has returned or the deadline trips. While polling it shows a live
398
+ per-minion checklist (spinner -> tick), whose trailing columns come from
399
+ ``cells_for(value)`` (``n_cells`` of them). Once the run is done the live
400
+ view is cleared and this returns ``(returns, dead, expected, start)`` for
401
+ the caller to render — or ``None`` if no job started (already reported).
402
+ ``call(name, **kw)`` invokes the named salt-api client."""
385
403
  submit = call("local_async", **payload)
386
404
  info: Any = _first_return(submit)
387
405
  jid = info.get("jid")
388
406
  if not jid:
389
407
  # No job id: nothing matched, or salt-api answered with an error body.
390
408
  console.print_json(json.dumps(submit))
391
- return
409
+ return None
392
410
 
393
- targeted = sorted(info.get("minions") or [])
411
+ targeted = sorted(info.get("minions") or [], key=_natural_key)
394
412
  if not targeted:
395
413
  console.print("(no minions matched the target)")
396
- return
414
+ return None
397
415
 
398
416
  expected = set(targeted) # shrinks as unreachable minions are dropped
399
417
  console.print(f"[dim]job {jid} -> {len(targeted)} minion(s)[/]")
@@ -402,6 +420,12 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
402
420
  dead: set[str] = set() # probed and confirmed not running the job
403
421
  spinner = Spinner("dots", style="cyan")
404
422
 
423
+ def view() -> Group:
424
+ done = expected & set(returns)
425
+ return _live_view(
426
+ targeted, returns, done, dead, spinner, n_cells=n_cells, cells_for=cells_for
427
+ )
428
+
405
429
  # transient=False keeps the finished checklist on screen above the
406
430
  # rendered tables, as a persistent at-a-glance record of the run.
407
431
  with Live(console=console, refresh_per_second=12, transient=False) as live:
@@ -417,7 +441,7 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
417
441
  now = time.monotonic()
418
442
  if len(done) != prev_done:
419
443
  prev_done, last_change = len(done), now
420
- live.update(_live_view(targeted, returns, done, dead, spinner))
444
+ live.update(view())
421
445
 
422
446
  if not expected - done:
423
447
  break
@@ -431,7 +455,7 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
431
455
  dead |= gone
432
456
  expected -= gone
433
457
  last_change = now # don't re-probe every single poll
434
- live.update(_live_view(targeted, returns, done, dead, spinner))
458
+ live.update(view())
435
459
  if not expected - done:
436
460
  break
437
461
 
@@ -439,20 +463,36 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
439
463
  break
440
464
  time.sleep(_POLL_INTERVAL)
441
465
 
442
- # Live view cleared — render the coloured tables, one block per minion.
443
- _print_state_result({"return": [returns]})
466
+ return returns, dead, expected, start
467
+
468
+
469
+ def _print_stragglers(dead: set[str], stalled: list[str]) -> None:
470
+ """The shared trailer for a streamed job: who never answered and who was
471
+ still running when the deadline tripped."""
444
472
  if dead:
445
473
  console.print(
446
- f"[yellow]no response from: {', '.join(sorted(dead))} "
474
+ f"[yellow]no response from: {', '.join(sorted(dead, key=_natural_key))} "
447
475
  f"(down, or no longer running the job)[/]"
448
476
  )
449
- stalled = sorted(expected - set(returns) - dead)
450
477
  if stalled:
451
478
  console.print(
452
479
  f"[yellow]still running at the {int(_POLL_DEADLINE)}s deadline: "
453
480
  f"{', '.join(stalled)}[/]"
454
481
  )
455
482
 
483
+
484
+ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) -> None:
485
+ """Stream a state job, then render the coloured per-minion tables and a
486
+ fleet-wide summary."""
487
+ result = _stream_job(call, payload, n_cells=5, cells_for=_state_cells)
488
+ if result is None:
489
+ return
490
+ returns, dead, expected, start = result
491
+
492
+ # Live view cleared — render the coloured tables, one block per minion.
493
+ _print_state_result({"return": [returns]})
494
+ _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
495
+
456
496
  # Fleet-wide summary: totals across all minions + wall-clock elapsed.
457
497
  totals, n = _grand_totals(returns)
458
498
  if n:
@@ -489,13 +529,22 @@ def run_state(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) ->
489
529
  # --------------------------------------------------------------------------
490
530
 
491
531
 
532
+ def _natural_key(name: str) -> list[object]:
533
+ """Sort key that orders embedded numbers numerically (bml2 before bml10)."""
534
+ return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", name)]
535
+
536
+
492
537
  def _print_key_panels(data: dict[str, Any]) -> None:
493
- """Render key.list_all as one panel per acceptance status."""
494
- panels: list[Panel] = []
538
+ """Render key.list_all as one stacked panel per acceptance status, the
539
+ IDs flowed into aligned columns inside each panel."""
495
540
  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(
541
+ keys: list[str] = sorted(data.get(status_key, []), key=_natural_key)
542
+ body: Any = (
543
+ Columns([Text(k) for k in keys], padding=(0, 2))
544
+ if keys
545
+ else Text("(none)", style="dim")
546
+ )
547
+ console.print(
499
548
  Panel(
500
549
  body,
501
550
  title=f"{label} ({len(keys)})",
@@ -503,7 +552,6 @@ def _print_key_panels(data: dict[str, Any]) -> None:
503
552
  border_style=color,
504
553
  )
505
554
  )
506
- console.print(Columns(panels, equal=True, expand=False))
507
555
 
508
556
 
509
557
  def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
@@ -536,3 +584,113 @@ def run_keys(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> N
536
584
  label = _KEY_PANELS.get(status_key, (status_key, "white"))[0]
537
585
  joined = ", ".join(ids) if ids else "[dim](none)[/]"
538
586
  console.print(f"{label}: {joined}")
587
+
588
+
589
+ # --------------------------------------------------------------------------
590
+ # command execution
591
+ # --------------------------------------------------------------------------
592
+
593
+
594
+ def _print_cmd_one(minion: str, val: Any) -> None:
595
+ """Render one minion's ``cmd.run_all`` reply: a bold id with its exit code
596
+ (green for 0, red otherwise), then stdout and any stderr indented beneath.
597
+
598
+ Falls back to printing the raw value for any non-dict shape — e.g. a
599
+ minion that errored before the command ran, where salt returns a string."""
600
+ if not isinstance(val, dict):
601
+ console.print(Text(minion, style="bold"))
602
+ console.print(Padding(Text(str(val)), (0, 0, 0, 2)))
603
+ return
604
+
605
+ record = cast("dict[str, Any]", val)
606
+ retcode = record.get("retcode")
607
+ header = Text(minion, style="bold")
608
+ if retcode == 0:
609
+ header.append(" exit 0", style="green")
610
+ elif retcode is not None:
611
+ header.append(f" exit {retcode}", style="red")
612
+ console.print(header)
613
+
614
+ stdout = str(record.get("stdout", "")).rstrip()
615
+ stderr = str(record.get("stderr", "")).rstrip()
616
+ if stdout:
617
+ console.print(Padding(Text(stdout), (0, 0, 0, 2)))
618
+ if stderr:
619
+ console.print(Padding(Text("stderr:", style="red"), (0, 0, 0, 2)))
620
+ console.print(Padding(Text(stderr, style="red"), (0, 0, 0, 4)))
621
+ if not stdout and not stderr:
622
+ console.print(Padding(Text("(no output)", style="dim"), (0, 0, 0, 2)))
623
+
624
+
625
+ def _print_cmd_result(resp: dict[str, Any]) -> None:
626
+ """Render a ``cmd.run_all`` reply, one block per minion (naturally sorted)."""
627
+ ret = _first_return(resp)
628
+ if not isinstance(ret, dict) or not ret:
629
+ console.print("(no minions responded)")
630
+ return
631
+ results = cast("dict[str, Any]", ret)
632
+ for minion in sorted(results, key=_natural_key):
633
+ _print_cmd_one(minion, results[minion])
634
+
635
+
636
+ def _cmd_cells(val: Any) -> list[Text]:
637
+ """The single live-view column for a finished minion's ``cmd.run_all``
638
+ reply: its exit code, green for 0 and red otherwise."""
639
+ if isinstance(val, dict):
640
+ retcode = cast("dict[str, Any]", val).get("retcode")
641
+ if retcode == 0:
642
+ return [Text("exit 0", style="green")]
643
+ if retcode is not None:
644
+ return [Text(f"exit {retcode}", style="red")]
645
+ return [Text("(no output)", style="dim")]
646
+
647
+
648
+ def _stream_cmd(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) -> None:
649
+ """Stream a ``cmd.run_all`` job, then render each minion's output block and
650
+ a fleet-wide ok/failed summary."""
651
+ result = _stream_job(call, payload, n_cells=1, cells_for=_cmd_cells)
652
+ if result is None:
653
+ return
654
+ returns, dead, expected, start = result
655
+
656
+ _print_cmd_result({"return": [returns]})
657
+ _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
658
+
659
+ n = len(returns)
660
+ if n:
661
+ ok = sum(
662
+ 1
663
+ for v in returns.values()
664
+ if isinstance(v, dict) and cast("dict[str, Any]", v).get("retcode") == 0
665
+ )
666
+ fail = n - ok
667
+ wall = _fmt_duration((time.monotonic() - start) * 1000.0)
668
+ tally = f"[green]{ok} ok[/] " + (
669
+ f"[red]{fail} failed[/]" if fail else f"{fail} failed"
670
+ )
671
+ console.print("[dim]===[/]")
672
+ console.print(f"[bold]{n} minion(s)[/] {tally} [dim]took {wall}[/]")
673
+
674
+
675
+ def run_cmd(args: argparse.Namespace, call: Callable[..., dict[str, Any]]) -> None:
676
+ """The ``salt cmd`` command, layered over ``local_async`` + ``cmd.run_all``.
677
+
678
+ Runs a shell command on the targeted minions and streams the results: a
679
+ live per-minion checklist (spinner -> exit code) while the job runs, then a
680
+ readable block per minion (exit code, stdout, stderr) and an ok/failed
681
+ summary — instead of the raw JSON the low-level ``local`` command emits.
682
+ Like ``state``, it fires the job async and polls the runner so a slow or
683
+ wide command never holds one long connection open against the gateway cap.
684
+ Trailing ``key=value`` args are forwarded as kwargs to ``cmd.run_all``
685
+ (e.g. ``shell=powershell``, ``cwd=...``, ``runas=...``). ``call(name,
686
+ **kw)`` invokes the named salt-api client (cli.py binds it to the
687
+ configured connection)."""
688
+ pos, kw = split_args(list(getattr(args, "args", None) or []))
689
+ payload: dict[str, Any] = {
690
+ "tgt": args.target,
691
+ "fun": "cmd.run_all",
692
+ "arg": [args.cmdline, *pos],
693
+ }
694
+ if kw:
695
+ payload["kwarg"] = kw
696
+ _stream_cmd(call, payload)
@@ -0,0 +1 @@
1
+ __version__ = "1.4.0"
@@ -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.0
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