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.
- {salt_api_cli-1.3.0/salt_api_cli.egg-info → salt_api_cli-1.4.0}/PKG-INFO +19 -12
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/README.md +18 -11
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli/cli.py +31 -7
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli/highlevel.py +195 -37
- salt_api_cli-1.4.0/salt_api_cli/version.py +1 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0/salt_api_cli.egg-info}/PKG-INFO +19 -12
- salt_api_cli-1.3.0/salt_api_cli/version.py +0 -1
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/MANIFEST.in +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/pyproject.toml +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli/__init__.py +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli/__main__.py +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli/lowlevel.py +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli/py.typed +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/SOURCES.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/dependency_links.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/entry_points.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/requires.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/top_level.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: salt-api-cli
|
|
3
|
-
Version: 1.
|
|
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
|
|
80
|
-
salt local
|
|
81
|
-
salt local
|
|
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
|
|
99
|
-
salt state test
|
|
100
|
-
salt state apply
|
|
101
|
-
salt state apply
|
|
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
|
|
64
|
-
salt local
|
|
65
|
-
salt local
|
|
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
|
|
83
|
-
salt state test
|
|
84
|
-
salt state apply
|
|
85
|
-
salt state apply
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
344
|
-
|
|
345
|
-
the unreachable, under a one-line status header.
|
|
346
|
-
|
|
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(
|
|
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("
|
|
368
|
+
grid.add_row(Text("X", style="red"), Text(minion, style="dim"), *blanks)
|
|
355
369
|
elif minion in done:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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 =
|
|
498
|
-
|
|
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
|
+
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
|
|
80
|
-
salt local
|
|
81
|
-
salt local
|
|
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
|
|
99
|
-
salt state test
|
|
100
|
-
salt state apply
|
|
101
|
-
salt state apply
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|