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.
- salt_api_cli-1.2.0/PKG-INFO +122 -0
- salt_api_cli-1.2.0/README.md +106 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/pyproject.toml +1 -0
- salt_api_cli-1.2.0/salt_api_cli/cli.py +204 -0
- salt_api_cli-1.2.0/salt_api_cli/highlevel.py +255 -0
- salt_api_cli-1.1.0/salt_api_cli/cli.py → salt_api_cli-1.2.0/salt_api_cli/lowlevel.py +11 -198
- salt_api_cli-1.2.0/salt_api_cli/version.py +1 -0
- salt_api_cli-1.2.0/salt_api_cli.egg-info/PKG-INFO +122 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli.egg-info/SOURCES.txt +2 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli.egg-info/requires.txt +1 -0
- salt_api_cli-1.1.0/PKG-INFO +0 -82
- salt_api_cli-1.1.0/README.md +0 -67
- salt_api_cli-1.1.0/salt_api_cli/version.py +0 -1
- salt_api_cli-1.1.0/salt_api_cli.egg-info/PKG-INFO +0 -82
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/MANIFEST.in +0 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli/__init__.py +0 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli/__main__.py +0 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli/py.typed +0 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli.egg-info/dependency_links.txt +0 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli.egg-info/entry_points.txt +0 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/salt_api_cli.egg-info/top_level.txt +0 -0
- {salt_api_cli-1.1.0 → salt_api_cli-1.2.0}/setup.cfg +0 -0
|
@@ -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.
|
|
@@ -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}")
|