salt-api-cli 1.1.0__tar.gz → 1.2.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.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: salt-api-cli
3
+ Version: 1.2.0
4
+ Summary: CLI to access salt-api
5
+ Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/sandbox-pokhara/saltapi-cli
8
+ Project-URL: Issues, https://github.com/sandbox-pokhara/saltapi-cli/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: rich>=15.0.0
13
+ Requires-Dist: typeguard>=4.5.2
14
+ Provides-Extra: pre-commit
15
+ Requires-Dist: pre-commit; extra == "pre-commit"
16
+
17
+ # salt-api-cli
18
+
19
+ Thin Python CLI for [salt-api](https://docs.saltproject.io/en/latest/ref/netapi/all/salt.netapi.rest_cherrypy.html).
20
+ Depends only on the standard library plus [`rich`](https://github.com/Textualize/rich)
21
+ (readable output) and [`typeguard`](https://github.com/agronholm/typeguard)
22
+ (JSON validation).
23
+
24
+ Logs in once with PAM credentials, caches the token in
25
+ `~/.cache/salt-api-cli/token.json`, then invokes salt-api's `local`,
26
+ `runner`, and `wheel` clients over HTTPS. The cached token self-heals:
27
+ it is refreshed proactively when its stored expiry has passed, and
28
+ reactively when the server rejects it (e.g. after the salt-master
29
+ container restarts and wipes its session store) — on rejection the CLI
30
+ discards the token, logs in again, and retries the request once.
31
+
32
+ Commands come in two layers:
33
+
34
+ - **Low-level** (`local`, `runner`, `wheel`) map directly to the salt-api
35
+ clients and print **raw JSON**.
36
+ - **High-level** (`state`, `keys`) wrap those clients and render
37
+ **readable, colorized output** with `rich`.
38
+
39
+ ## Installation
40
+
41
+ ```
42
+ pip install salt-api-cli
43
+ ```
44
+
45
+ ## Configuration
46
+
47
+ Configuration is resolved in this order (later sources override earlier):
48
+
49
+ 1. `~/.saltapiclirc` — INI file, `[salt-api-cli]` section
50
+ 2. Environment variables — `SALT_API_URL`, `SALT_API_USER`, `SALT_API_PASS`, `SALT_API_INSECURE`
51
+ 3. Command-line flags — `--url`, `--user`, `--password`, `--insecure`, `--relogin`, `--no-token-cache`
52
+
53
+ Example `~/.saltapiclirc`:
54
+
55
+ ```ini
56
+ [salt-api-cli]
57
+ url = https://salt.example.com
58
+ user = salt_api
59
+ password = secret
60
+ insecure = false
61
+ ```
62
+
63
+ `SALT_API_INSECURE=1` (or `insecure = true` in the config) skips TLS
64
+ certificate verification.
65
+
66
+ Token cache control: `--relogin` ignores any cached token and logs in
67
+ fresh (re-caching the new token); `--no-token-cache` neither reads nor
68
+ writes the cache for that run; `salt logout` discards the cached token.
69
+
70
+ ## Usage
71
+
72
+ ### Low-level commands (raw JSON)
73
+
74
+ These map one-to-one to the salt-api clients and print the response
75
+ verbatim as indented JSON.
76
+
77
+ ```
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
82
+
83
+ # Runner client (master-side: manage.status, jobs.list_jobs, ...)
84
+ salt runner manage.status
85
+ salt runner jobs.list_jobs
86
+
87
+ # Wheel client (master-side, low-level)
88
+ salt wheel key.list_all
89
+ ```
90
+
91
+ ### High-level commands (readable, colorized)
92
+
93
+ These wrap the low-level clients and render their output with `rich`.
94
+
95
+ ```
96
+ # State runs — a colored table of states, one row each, with a summary.
97
+ # 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
102
+
103
+ # Key management — wraps the wheel client's key.* functions.
104
+ # `keys list` shows one colored panel per status (Accepted/Pending/Denied/Rejected).
105
+ salt keys list
106
+ salt keys accept <id-or-glob>
107
+ salt keys accept-all
108
+ salt keys reject <id-or-glob>
109
+ salt keys delete <id-or-glob>
110
+ ```
111
+
112
+ Color and panels appear when writing to a terminal; output is plain when
113
+ piped to a file or pager.
114
+
115
+ Any `key=value` argument is parsed as a kwarg to the salt function;
116
+ anything else is positional.
117
+
118
+ You can also invoke the CLI as a module: `python -m salt_api_cli ...`.
119
+
120
+ ## License
121
+
122
+ This project is licensed under the terms of the MIT license.
@@ -0,0 +1,106 @@
1
+ # salt-api-cli
2
+
3
+ Thin Python CLI for [salt-api](https://docs.saltproject.io/en/latest/ref/netapi/all/salt.netapi.rest_cherrypy.html).
4
+ Depends only on the standard library plus [`rich`](https://github.com/Textualize/rich)
5
+ (readable output) and [`typeguard`](https://github.com/agronholm/typeguard)
6
+ (JSON validation).
7
+
8
+ Logs in once with PAM credentials, caches the token in
9
+ `~/.cache/salt-api-cli/token.json`, then invokes salt-api's `local`,
10
+ `runner`, and `wheel` clients over HTTPS. The cached token self-heals:
11
+ it is refreshed proactively when its stored expiry has passed, and
12
+ reactively when the server rejects it (e.g. after the salt-master
13
+ container restarts and wipes its session store) — on rejection the CLI
14
+ discards the token, logs in again, and retries the request once.
15
+
16
+ Commands come in two layers:
17
+
18
+ - **Low-level** (`local`, `runner`, `wheel`) map directly to the salt-api
19
+ clients and print **raw JSON**.
20
+ - **High-level** (`state`, `keys`) wrap those clients and render
21
+ **readable, colorized output** with `rich`.
22
+
23
+ ## Installation
24
+
25
+ ```
26
+ pip install salt-api-cli
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ Configuration is resolved in this order (later sources override earlier):
32
+
33
+ 1. `~/.saltapiclirc` — INI file, `[salt-api-cli]` section
34
+ 2. Environment variables — `SALT_API_URL`, `SALT_API_USER`, `SALT_API_PASS`, `SALT_API_INSECURE`
35
+ 3. Command-line flags — `--url`, `--user`, `--password`, `--insecure`, `--relogin`, `--no-token-cache`
36
+
37
+ Example `~/.saltapiclirc`:
38
+
39
+ ```ini
40
+ [salt-api-cli]
41
+ url = https://salt.example.com
42
+ user = salt_api
43
+ password = secret
44
+ insecure = false
45
+ ```
46
+
47
+ `SALT_API_INSECURE=1` (or `insecure = true` in the config) skips TLS
48
+ certificate verification.
49
+
50
+ Token cache control: `--relogin` ignores any cached token and logs in
51
+ fresh (re-caching the new token); `--no-token-cache` neither reads nor
52
+ writes the cache for that run; `salt logout` discards the cached token.
53
+
54
+ ## Usage
55
+
56
+ ### Low-level commands (raw JSON)
57
+
58
+ These map one-to-one to the salt-api clients and print the response
59
+ verbatim as indented JSON.
60
+
61
+ ```
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
66
+
67
+ # Runner client (master-side: manage.status, jobs.list_jobs, ...)
68
+ salt runner manage.status
69
+ salt runner jobs.list_jobs
70
+
71
+ # Wheel client (master-side, low-level)
72
+ salt wheel key.list_all
73
+ ```
74
+
75
+ ### High-level commands (readable, colorized)
76
+
77
+ These wrap the low-level clients and render their output with `rich`.
78
+
79
+ ```
80
+ # State runs — a colored table of states, one row each, with a summary.
81
+ # 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
86
+
87
+ # Key management — wraps the wheel client's key.* functions.
88
+ # `keys list` shows one colored panel per status (Accepted/Pending/Denied/Rejected).
89
+ salt keys list
90
+ salt keys accept <id-or-glob>
91
+ salt keys accept-all
92
+ salt keys reject <id-or-glob>
93
+ salt keys delete <id-or-glob>
94
+ ```
95
+
96
+ Color and panels appear when writing to a terminal; output is plain when
97
+ piped to a file or pager.
98
+
99
+ Any `key=value` argument is parsed as a kwarg to the salt function;
100
+ anything else is positional.
101
+
102
+ You can also invoke the CLI as a module: `python -m salt_api_cli ...`.
103
+
104
+ ## License
105
+
106
+ This project is licensed under the terms of the MIT license.
@@ -12,6 +12,7 @@ license = "MIT"
12
12
  keywords = []
13
13
  classifiers = ["Programming Language :: Python :: 3"]
14
14
  dependencies = [
15
+ "rich>=15.0.0",
15
16
  "typeguard>=4.5.2",
16
17
  ]
17
18
  dynamic = ["version"]
@@ -0,0 +1,204 @@
1
+ """salt-api-cli — thin Python CLI for salt-api.
2
+
3
+ Logs in once with PAM creds, caches the token in
4
+ ~/.cache/salt-api-cli/token.json, then invokes the salt-api local/
5
+ runner/wheel clients over HTTPS. Depends only on the stdlib plus
6
+ ``typeguard`` for validating cached/responded JSON.
7
+
8
+ This module is the CLI glue: it parses arguments and dispatches each
9
+ command, wiring the low-level transport (:mod:`salt_api_cli.lowlevel`) to
10
+ the high-level human-readable rendering (:mod:`salt_api_cli.highlevel`).
11
+
12
+ The cached token self-heals: it is refreshed proactively when its stored
13
+ expiry has passed, and reactively when the server rejects it (HTTP 401 or
14
+ an EAUTH body) — e.g. after the salt-master container restarts and wipes
15
+ its session store. `--relogin` forces a fresh login, `--no-token-cache`
16
+ skips the cache entirely, and the `logout` subcommand discards the token.
17
+
18
+ Configuration (later sources override earlier):
19
+ 1. ~/.saltapiclirc INI file, [salt-api-cli] section
20
+ 2. environment variables SALT_API_URL, SALT_API_USER,
21
+ SALT_API_PASS, SALT_API_INSECURE
22
+ 3. command-line flags --url, --user, --password,
23
+ --insecure, --relogin,
24
+ --no-token-cache
25
+
26
+ Any `key=value` argument to local/runner/wheel is parsed as a kwarg to
27
+ the salt function. Anything else is positional.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ from typing import Any
35
+
36
+ from salt_api_cli import highlevel
37
+ from salt_api_cli.lowlevel import (
38
+ TOKEN_FILE,
39
+ Config,
40
+ SaltApiError,
41
+ call,
42
+ clear_token,
43
+ load_config,
44
+ split_args,
45
+ )
46
+
47
+
48
+ def _run_local(cfg: Config, args: argparse.Namespace) -> None:
49
+ pos, kw = split_args(list(args.args))
50
+ payload: dict[str, Any] = {"tgt": args.target, "fun": args.function, "arg": pos}
51
+ if kw:
52
+ payload["kwarg"] = kw
53
+ print(json.dumps(call(cfg, "local", **payload), indent=2))
54
+
55
+
56
+ def _run_client(cfg: Config, client: str, args: argparse.Namespace) -> None:
57
+ pos, kw = split_args(list(args.args))
58
+ payload: dict[str, Any] = {"fun": args.function, "arg": pos}
59
+ if kw:
60
+ payload["kwarg"] = kw
61
+ print(json.dumps(call(cfg, client, **payload), indent=2))
62
+
63
+
64
+ def _run_state(cfg: Config, args: argparse.Namespace) -> None:
65
+ def local(**kw: Any) -> dict[str, Any]:
66
+ return call(cfg, "local", **kw)
67
+
68
+ highlevel.run_state(args, local)
69
+
70
+
71
+ def _run_keys(cfg: Config, args: argparse.Namespace) -> None:
72
+ def wheel(**kw: Any) -> dict[str, Any]:
73
+ return call(cfg, "wheel", **kw)
74
+
75
+ highlevel.run_keys(args, wheel)
76
+
77
+
78
+ def _build_parser() -> argparse.ArgumentParser:
79
+ parser = argparse.ArgumentParser(
80
+ prog="salt",
81
+ description="Thin Python CLI for salt-api.",
82
+ formatter_class=argparse.RawDescriptionHelpFormatter,
83
+ epilog=(
84
+ "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"
88
+ " salt runner manage.status\n"
89
+ " salt wheel key.list_all\n"
90
+ "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"
94
+ " salt keys list\n"
95
+ " salt keys accept '<id-or-glob>'\n"
96
+ " salt keys accept-all\n"
97
+ ),
98
+ )
99
+ parser.add_argument("--url", help="salt-api base URL")
100
+ parser.add_argument("--user", help="PAM username")
101
+ parser.add_argument("--password", help="PAM password")
102
+ parser.add_argument(
103
+ "--insecure",
104
+ action="store_true",
105
+ help="skip TLS certificate verification",
106
+ )
107
+ parser.add_argument(
108
+ "--relogin",
109
+ action="store_true",
110
+ help="ignore any cached token and log in fresh (re-caches the new token)",
111
+ )
112
+ parser.add_argument(
113
+ "--no-token-cache",
114
+ dest="no_token_cache",
115
+ action="store_true",
116
+ help="do not read or write the token cache for this run",
117
+ )
118
+
119
+ sub = parser.add_subparsers(dest="command", required=True)
120
+
121
+ p_local = sub.add_parser("local", help="run a function on minions")
122
+ p_local.add_argument("target", help="minion target (id or glob)")
123
+ p_local.add_argument("function", help="salt function (e.g. test.ping)")
124
+ p_local.add_argument(
125
+ "args", nargs=argparse.REMAINDER, help="positional and key=value args"
126
+ )
127
+
128
+ p_runner = sub.add_parser("runner", help="invoke a master-side runner")
129
+ p_runner.add_argument("function")
130
+ p_runner.add_argument("args", nargs=argparse.REMAINDER)
131
+
132
+ p_wheel = sub.add_parser("wheel", help="invoke a master-side wheel function")
133
+ p_wheel.add_argument("function")
134
+ p_wheel.add_argument("args", nargs=argparse.REMAINDER)
135
+
136
+ p_state = sub.add_parser("state", help="apply states with readable output")
137
+ state_sub = p_state.add_subparsers(dest="action", required=True)
138
+ p_highstate = state_sub.add_parser("highstate", help="apply the highstate")
139
+ p_highstate.add_argument("target", help="minion target (id or glob)")
140
+ p_highstate.add_argument(
141
+ "args", nargs=argparse.REMAINDER, help="key=value args, e.g. test=True"
142
+ )
143
+ p_test = state_sub.add_parser(
144
+ "test", help="dry-run the highstate (forces test=True)"
145
+ )
146
+ p_test.add_argument("target", help="minion target (id or glob)")
147
+ p_test.add_argument("args", nargs=argparse.REMAINDER, help="extra key=value args")
148
+ p_apply = state_sub.add_parser("apply", help="apply specific sls module(s)")
149
+ p_apply.add_argument("target", help="minion target (id or glob)")
150
+ p_apply.add_argument("sls", help="sls module to apply (e.g. veyon or veyon.ldap)")
151
+ p_apply.add_argument(
152
+ "args", nargs=argparse.REMAINDER, help="key=value args, e.g. test=True"
153
+ )
154
+
155
+ p_keys = sub.add_parser("keys", help="manage minion keys")
156
+ keys_sub = p_keys.add_subparsers(dest="action", required=True)
157
+ keys_sub.add_parser("list", help="show keys grouped by status")
158
+ p_accept = keys_sub.add_parser("accept", help="accept a key by id or glob")
159
+ p_accept.add_argument("match")
160
+ keys_sub.add_parser("accept-all", help="accept every pending key")
161
+ p_reject = keys_sub.add_parser("reject", help="reject a key by id or glob")
162
+ p_reject.add_argument("match")
163
+ p_delete = keys_sub.add_parser("delete", help="delete a key by id or glob")
164
+ p_delete.add_argument("match")
165
+
166
+ sub.add_parser("logout", help="discard the cached auth token")
167
+
168
+ return parser
169
+
170
+
171
+ def main() -> None:
172
+ parser = _build_parser()
173
+ args = parser.parse_args()
174
+
175
+ # logout needs no server config — it just drops the local token file.
176
+ if args.command == "logout":
177
+ existed = TOKEN_FILE.exists()
178
+ clear_token()
179
+ print(
180
+ f"discarded cached token ({TOKEN_FILE})"
181
+ if existed
182
+ else f"no cached token to discard ({TOKEN_FILE})"
183
+ )
184
+ return
185
+
186
+ cfg = load_config(args)
187
+
188
+ try:
189
+ if args.command == "local":
190
+ _run_local(cfg, args)
191
+ elif args.command == "runner":
192
+ _run_client(cfg, "runner", args)
193
+ elif args.command == "wheel":
194
+ _run_client(cfg, "wheel", args)
195
+ elif args.command == "state":
196
+ _run_state(cfg, args)
197
+ elif args.command == "keys":
198
+ _run_keys(cfg, args)
199
+ except SaltApiError as e:
200
+ raise SystemExit(str(e))
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
@@ -0,0 +1,255 @@
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}")