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.
- {salt_api_cli-1.2.0/salt_api_cli.egg-info → salt_api_cli-1.4.0}/PKG-INFO +19 -12
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/README.md +18 -11
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli/cli.py +34 -10
- salt_api_cli-1.4.0/salt_api_cli/highlevel.py +696 -0
- salt_api_cli-1.4.0/salt_api_cli/version.py +1 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0/salt_api_cli.egg-info}/PKG-INFO +19 -12
- salt_api_cli-1.2.0/salt_api_cli/highlevel.py +0 -255
- salt_api_cli-1.2.0/salt_api_cli/version.py +0 -1
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/MANIFEST.in +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/pyproject.toml +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli/__init__.py +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli/__main__.py +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli/lowlevel.py +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli/py.typed +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/SOURCES.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/dependency_links.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/entry_points.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/requires.txt +0 -0
- {salt_api_cli-1.2.0 → salt_api_cli-1.4.0}/salt_api_cli.egg-info/top_level.txt +0 -0
- {salt_api_cli-1.2.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
|
|
@@ -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
|
|
66
|
-
return call(cfg,
|
|
65
|
+
def client(name: str, **kw: Any) -> dict[str, Any]:
|
|
66
|
+
return call(cfg, name, **kw)
|
|
67
67
|
|
|
68
|
-
highlevel.run_state(args,
|
|
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
|
-
|
|
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":
|
|
@@ -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.
|
|
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,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
|
|
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
|