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