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.
- {salt_api_cli-1.3.0/salt_api_cli.egg-info → salt_api_cli-1.4.1}/PKG-INFO +19 -12
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/README.md +18 -11
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli/cli.py +31 -7
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli/highlevel.py +226 -46
- salt_api_cli-1.4.1/salt_api_cli/version.py +1 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1/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.1}/MANIFEST.in +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/pyproject.toml +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli/__init__.py +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli/__main__.py +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli/lowlevel.py +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli/py.typed +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli.egg-info/SOURCES.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli.egg-info/dependency_links.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli.egg-info/entry_points.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli.egg-info/requires.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/salt_api_cli.egg-info/top_level.txt +0 -0
- {salt_api_cli-1.3.0 → salt_api_cli-1.4.1}/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.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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
344
|
-
|
|
345
|
-
the unreachable, under a one-line status header.
|
|
346
|
-
|
|
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(
|
|
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("
|
|
384
|
+
grid.add_row(Text("X", style="red"), Text(minion, style="dim"), *blanks)
|
|
355
385
|
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)
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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 =
|
|
498
|
-
|
|
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
|
+
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
|
|
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
|