salt-api-cli 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -9,6 +9,7 @@ Project-URL: Issues, https://github.com/sandbox-pokhara/saltapi-cli/issues
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Requires-Python: >=3.11
11
11
  Description-Content-Type: text/markdown
12
+ Requires-Dist: typeguard>=4.5.2
12
13
  Provides-Extra: pre-commit
13
14
  Requires-Dist: pre-commit; extra == "pre-commit"
14
15
 
@@ -52,23 +53,23 @@ certificate verification.
52
53
 
53
54
  ```
54
55
  # Local client — fan out to minions
55
- salt-api-cli local '*' test.ping
56
- salt-api-cli local 'bml*' cmd.run 'whoami'
57
- salt-api-cli local 'bml1' cmd.run 'Get-Date' shell=powershell
56
+ salt local '*' test.ping
57
+ salt local 'bml*' cmd.run 'whoami'
58
+ salt local 'bml1' cmd.run 'Get-Date' shell=powershell
58
59
 
59
60
  # Runner client (master-side: manage.status, jobs.list_jobs, ...)
60
- salt-api-cli runner manage.status
61
- salt-api-cli runner jobs.list_jobs
61
+ salt runner manage.status
62
+ salt runner jobs.list_jobs
62
63
 
63
64
  # Wheel client (master-side, low-level)
64
- salt-api-cli wheel key.list_all
65
+ salt wheel key.list_all
65
66
 
66
67
  # Key management (high-level wrapper around the wheel client)
67
- salt-api-cli keys list
68
- salt-api-cli keys accept <id-or-glob>
69
- salt-api-cli keys accept-all
70
- salt-api-cli keys reject <id-or-glob>
71
- salt-api-cli keys delete <id-or-glob>
68
+ salt keys list
69
+ salt keys accept <id-or-glob>
70
+ salt keys accept-all
71
+ salt keys reject <id-or-glob>
72
+ salt keys delete <id-or-glob>
72
73
  ```
73
74
 
74
75
  Any `key=value` argument is parsed as a kwarg to the salt function;
@@ -38,23 +38,23 @@ certificate verification.
38
38
 
39
39
  ```
40
40
  # Local client — fan out to minions
41
- salt-api-cli local '*' test.ping
42
- salt-api-cli local 'bml*' cmd.run 'whoami'
43
- salt-api-cli local 'bml1' cmd.run 'Get-Date' shell=powershell
41
+ salt local '*' test.ping
42
+ salt local 'bml*' cmd.run 'whoami'
43
+ salt local 'bml1' cmd.run 'Get-Date' shell=powershell
44
44
 
45
45
  # Runner client (master-side: manage.status, jobs.list_jobs, ...)
46
- salt-api-cli runner manage.status
47
- salt-api-cli runner jobs.list_jobs
46
+ salt runner manage.status
47
+ salt runner jobs.list_jobs
48
48
 
49
49
  # Wheel client (master-side, low-level)
50
- salt-api-cli wheel key.list_all
50
+ salt wheel key.list_all
51
51
 
52
52
  # Key management (high-level wrapper around the wheel client)
53
- salt-api-cli keys list
54
- salt-api-cli keys accept <id-or-glob>
55
- salt-api-cli keys accept-all
56
- salt-api-cli keys reject <id-or-glob>
57
- salt-api-cli keys delete <id-or-glob>
53
+ salt keys list
54
+ salt keys accept <id-or-glob>
55
+ salt keys accept-all
56
+ salt keys reject <id-or-glob>
57
+ salt keys delete <id-or-glob>
58
58
  ```
59
59
 
60
60
  Any `key=value` argument is parsed as a kwarg to the salt function;
@@ -11,14 +11,16 @@ readme = "README.md"
11
11
  license = "MIT"
12
12
  keywords = []
13
13
  classifiers = ["Programming Language :: Python :: 3"]
14
- dependencies = []
14
+ dependencies = [
15
+ "typeguard>=4.5.2",
16
+ ]
15
17
  dynamic = ["version"]
16
18
 
17
19
  [project.optional-dependencies]
18
20
  pre-commit = ["pre-commit"]
19
21
 
20
22
  [project.scripts]
21
- salt-api-cli = "salt_api_cli.cli:main"
23
+ salt = "salt_api_cli.cli:main"
22
24
 
23
25
  [project.urls]
24
26
  Homepage = "https://github.com/sandbox-pokhara/saltapi-cli"
@@ -1,15 +1,25 @@
1
1
  """salt-api-cli — thin Python CLI for salt-api.
2
2
 
3
- Stdlib-only. Logs in once with PAM creds, caches the token in
3
+ Logs in once with PAM creds, caches the token in
4
4
  ~/.cache/salt-api-cli/token.json, then invokes the salt-api local/
5
- runner/wheel clients over HTTPS. Token auto-refreshes when expired.
5
+ runner/wheel clients over HTTPS. Depends only on the stdlib plus
6
+ ``typeguard`` for validating cached/responded JSON.
7
+
8
+ The cached token self-heals: it is refreshed proactively when its stored
9
+ expiry has passed, and reactively when the server rejects it (HTTP 401 or
10
+ an EAUTH body) — e.g. after the salt-master container restarts and wipes
11
+ its session store. On rejection the CLI discards the token, logs in again,
12
+ and retries the request once before giving up. `--relogin` forces a fresh
13
+ login, `--no-token-cache` skips the cache entirely, and the `logout`
14
+ subcommand discards the cached token.
6
15
 
7
16
  Configuration (later sources override earlier):
8
17
  1. ~/.saltapiclirc INI file, [salt-api-cli] section
9
18
  2. environment variables SALT_API_URL, SALT_API_USER,
10
19
  SALT_API_PASS, SALT_API_INSECURE
11
20
  3. command-line flags --url, --user, --password,
12
- --insecure
21
+ --insecure, --relogin,
22
+ --no-token-cache
13
23
 
14
24
  Any `key=value` argument to local/runner/wheel is parsed as a kwarg to
15
25
  the salt function. Anything else is positional.
@@ -31,11 +41,48 @@ from urllib.error import HTTPError, URLError
31
41
  from urllib.parse import urlencode
32
42
  from urllib.request import Request, urlopen
33
43
 
44
+ from typeguard import TypeCheckError, check_type
45
+
34
46
  CONFIG_FILE = Path.home() / ".saltapiclirc"
35
47
  CONFIG_SECTION = "salt-api-cli"
36
48
  TOKEN_FILE = Path.home() / ".cache" / "salt-api-cli" / "token.json"
37
49
  USER_AGENT = "salt-api-cli/1.0 (Mozilla/5.0 compatible)"
38
50
 
51
+ # Treat a cached token as already gone this many seconds before its real
52
+ # expiry, so we never send one that lapses mid-flight.
53
+ TOKEN_EXPIRY_MARGIN = 60
54
+
55
+ # Substrings that mark a salt-api JSON body as an auth failure. salt-api
56
+ # usually answers an invalid token with HTTP 401, but it sometimes returns
57
+ # 200 with one of these in the payload instead.
58
+ _AUTH_FAIL_MARKERS = (
59
+ "eauth",
60
+ "no permission",
61
+ "not authorized",
62
+ "authentication denied",
63
+ "failed to authenticate",
64
+ )
65
+
66
+ _AUTH_FAIL_HINT = (
67
+ "salt-api authentication still failed after a fresh login — the "
68
+ "credentials may be wrong or the user lacks permission "
69
+ "(check --user/--password, SALT_API_USER/SALT_API_PASS, or "
70
+ "~/.saltapiclirc)."
71
+ )
72
+
73
+
74
+ class SaltApiError(Exception):
75
+ """A salt-api error whose message is safe to show the user verbatim."""
76
+
77
+
78
+ class AuthError(SaltApiError):
79
+ """An authentication failure (HTTP 401 or an EAUTH/auth-failure body).
80
+
81
+ Signals that the token in hand was rejected and a re-login should be
82
+ attempted.
83
+ """
84
+
85
+
39
86
  # Wheel key.list_all groups minion IDs by acceptance state under these keys.
40
87
  KEY_STATUS_LABELS = {
41
88
  "minions": "Accepted",
@@ -51,6 +98,10 @@ class Config:
51
98
  user: str
52
99
  password: str
53
100
  insecure: bool
101
+ # Ignore any cached token and log in fresh (the new token is still cached).
102
+ relogin: bool = False
103
+ # Neither read nor write the token cache for this run.
104
+ no_token_cache: bool = False
54
105
 
55
106
 
56
107
  def _truthy(value: str) -> bool:
@@ -96,6 +147,8 @@ def _load_config(args: argparse.Namespace) -> Config:
96
147
  user=user,
97
148
  password=password,
98
149
  insecure=insecure,
150
+ relogin=bool(getattr(args, "relogin", False)),
151
+ no_token_cache=bool(getattr(args, "no_token_cache", False)),
99
152
  )
100
153
 
101
154
 
@@ -115,9 +168,25 @@ def _http(req: Request, cfg: Config) -> dict[str, Any]:
115
168
  return data
116
169
  except HTTPError as e:
117
170
  body = e.read().decode(errors="replace")
118
- sys.exit(f"salt-api {e.code} {e.reason}: {body}")
171
+ if e.code == 401:
172
+ raise AuthError(f"salt-api 401 {e.reason}: {body}") from e
173
+ raise SaltApiError(f"salt-api {e.code} {e.reason}: {body}") from e
119
174
  except URLError as e:
120
- sys.exit(f"salt-api unreachable: {e.reason}")
175
+ raise SaltApiError(f"salt-api unreachable: {e.reason}") from e
176
+
177
+
178
+ def _is_auth_failure(result: dict[str, Any]) -> bool:
179
+ """True if a 200 response body is actually an EAUTH/auth-failure notice."""
180
+ texts: list[str] = []
181
+ try:
182
+ texts.extend(check_type(result.get("return"), list[str]))
183
+ except TypeCheckError:
184
+ pass # a normal result ("return" is a list of dicts) — not an auth body
185
+ for key in ("error", "status"):
186
+ val = result.get(key)
187
+ if isinstance(val, str):
188
+ texts.append(val)
189
+ return any(m in t.lower() for t in texts for m in _AUTH_FAIL_MARKERS)
121
190
 
122
191
 
123
192
  def _login(cfg: Config) -> dict[str, Any]:
@@ -136,37 +205,99 @@ def _login(cfg: Config) -> dict[str, Any]:
136
205
  return info
137
206
 
138
207
 
139
- def _get_token(cfg: Config) -> str:
140
- if TOKEN_FILE.exists():
141
- try:
142
- cached: dict[str, Any] = json.loads(TOKEN_FILE.read_text())
143
- if cached.get("expire", 0) > time.time() + 60:
144
- return str(cached["token"])
145
- except (json.JSONDecodeError, OSError, AttributeError, TypeError):
146
- pass
147
- info = _login(cfg)
148
- TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
149
- TOKEN_FILE.write_text(json.dumps(info))
208
+ def _read_cached_token(cfg: Config) -> str | None:
209
+ """Return a still-valid cached token, or None to force a fresh login.
210
+
211
+ Tolerant of a missing, empty, corrupt, or schema-mismatched token.json:
212
+ any problem reading it is treated as "no usable token". A token whose
213
+ `expire` is in the past (within a safety margin) is also discarded.
214
+ """
215
+ if cfg.relogin or cfg.no_token_cache:
216
+ return None
150
217
  try:
151
- os.chmod(TOKEN_FILE, 0o600)
218
+ raw = TOKEN_FILE.read_text()
219
+ except OSError:
220
+ return None
221
+ try:
222
+ cached = check_type(json.loads(raw), dict[str, Any])
223
+ except (json.JSONDecodeError, ValueError, TypeCheckError):
224
+ return None
225
+ token = cached.get("token")
226
+ if not token:
227
+ return None
228
+ try:
229
+ expire = float(cached.get("expire", 0))
230
+ except (TypeError, ValueError):
231
+ return None
232
+ if expire <= time.time() + TOKEN_EXPIRY_MARGIN:
233
+ return None
234
+ return str(token)
235
+
236
+
237
+ def _clear_token() -> None:
238
+ """Discard the cached token, if any. Never raises."""
239
+ try:
240
+ TOKEN_FILE.unlink()
152
241
  except OSError:
153
242
  pass
243
+
244
+
245
+ def _fresh_token(cfg: Config) -> str:
246
+ """Log in and (unless caching is disabled) persist the new token."""
247
+ info = _login(cfg)
248
+ if not cfg.no_token_cache:
249
+ try:
250
+ TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
251
+ TOKEN_FILE.write_text(json.dumps(info))
252
+ os.chmod(TOKEN_FILE, 0o600)
253
+ except OSError:
254
+ pass
154
255
  return str(info["token"])
155
256
 
156
257
 
258
+ def _get_token(cfg: Config) -> str:
259
+ cached = _read_cached_token(cfg)
260
+ if cached is not None:
261
+ return cached
262
+ return _fresh_token(cfg)
263
+
264
+
157
265
  def _call(cfg: Config, client: str, **kwargs: Any) -> dict[str, Any]:
158
266
  payload = [{"client": client, **kwargs}]
159
- req = Request(
160
- cfg.url,
161
- data=json.dumps(payload).encode(),
162
- headers={
163
- "Accept": "application/json",
164
- "Content-Type": "application/json",
165
- "X-Auth-Token": _get_token(cfg),
166
- "User-Agent": USER_AGENT,
167
- },
168
- )
169
- return _http(req, cfg)
267
+ body = json.dumps(payload).encode()
268
+
269
+ def attempt(token: str) -> dict[str, Any]:
270
+ req = Request(
271
+ cfg.url,
272
+ data=body,
273
+ headers={
274
+ "Accept": "application/json",
275
+ "Content-Type": "application/json",
276
+ "X-Auth-Token": token,
277
+ "User-Agent": USER_AGENT,
278
+ },
279
+ )
280
+ return _http(req, cfg)
281
+
282
+ # First try with whatever token we have (cached or freshly minted). A
283
+ # rejected token here means it went stale server-side (expiry, or the
284
+ # salt-master session store was wiped on restart) — not bad credentials.
285
+ try:
286
+ result = attempt(_get_token(cfg))
287
+ if not _is_auth_failure(result):
288
+ return result
289
+ except AuthError:
290
+ pass
291
+
292
+ # Discard the stale token, log in fresh, and retry exactly once.
293
+ _clear_token()
294
+ try:
295
+ result = attempt(_fresh_token(cfg))
296
+ except AuthError as e:
297
+ raise AuthError(f"{_AUTH_FAIL_HINT}\ndetails: {e}") from e
298
+ if _is_auth_failure(result):
299
+ raise AuthError(_AUTH_FAIL_HINT)
300
+ return result
170
301
 
171
302
 
172
303
  def _split_args(args: list[str]) -> tuple[list[str], dict[str, str]]:
@@ -254,19 +385,19 @@ def _run_keys(cfg: Config, args: argparse.Namespace) -> None:
254
385
 
255
386
  def _build_parser() -> argparse.ArgumentParser:
256
387
  parser = argparse.ArgumentParser(
257
- prog="salt-api-cli",
388
+ prog="salt",
258
389
  description="Thin Python CLI for salt-api.",
259
390
  formatter_class=argparse.RawDescriptionHelpFormatter,
260
391
  epilog=(
261
392
  "examples:\n"
262
- " salt-api-cli local '*' test.ping\n"
263
- " salt-api-cli local 'bml*' cmd.run 'whoami'\n"
264
- " salt-api-cli local 'bml1' cmd.run 'Get-Date' shell=powershell\n"
265
- " salt-api-cli runner manage.status\n"
266
- " salt-api-cli wheel key.list_all\n"
267
- " salt-api-cli keys list\n"
268
- " salt-api-cli keys accept '<id-or-glob>'\n"
269
- " salt-api-cli keys accept-all\n"
393
+ " salt local '*' test.ping\n"
394
+ " salt local 'bml*' cmd.run 'whoami'\n"
395
+ " salt local 'bml1' cmd.run 'Get-Date' shell=powershell\n"
396
+ " salt runner manage.status\n"
397
+ " salt wheel key.list_all\n"
398
+ " salt keys list\n"
399
+ " salt keys accept '<id-or-glob>'\n"
400
+ " salt keys accept-all\n"
270
401
  ),
271
402
  )
272
403
  parser.add_argument("--url", help="salt-api base URL")
@@ -277,6 +408,17 @@ def _build_parser() -> argparse.ArgumentParser:
277
408
  action="store_true",
278
409
  help="skip TLS certificate verification",
279
410
  )
411
+ parser.add_argument(
412
+ "--relogin",
413
+ action="store_true",
414
+ help="ignore any cached token and log in fresh (re-caches the new token)",
415
+ )
416
+ parser.add_argument(
417
+ "--no-token-cache",
418
+ dest="no_token_cache",
419
+ action="store_true",
420
+ help="do not read or write the token cache for this run",
421
+ )
280
422
 
281
423
  sub = parser.add_subparsers(dest="command", required=True)
282
424
 
@@ -306,22 +448,39 @@ def _build_parser() -> argparse.ArgumentParser:
306
448
  p_delete = keys_sub.add_parser("delete", help="delete a key by id or glob")
307
449
  p_delete.add_argument("match")
308
450
 
451
+ sub.add_parser("logout", help="discard the cached auth token")
452
+
309
453
  return parser
310
454
 
311
455
 
312
456
  def main() -> None:
313
457
  parser = _build_parser()
314
458
  args = parser.parse_args()
459
+
460
+ # logout needs no server config — it just drops the local token file.
461
+ if args.command == "logout":
462
+ existed = TOKEN_FILE.exists()
463
+ _clear_token()
464
+ print(
465
+ f"discarded cached token ({TOKEN_FILE})"
466
+ if existed
467
+ else f"no cached token to discard ({TOKEN_FILE})"
468
+ )
469
+ return
470
+
315
471
  cfg = _load_config(args)
316
472
 
317
- if args.command == "local":
318
- _run_local(cfg, args)
319
- elif args.command == "runner":
320
- _run_client(cfg, "runner", args)
321
- elif args.command == "wheel":
322
- _run_client(cfg, "wheel", args)
323
- elif args.command == "keys":
324
- _run_keys(cfg, args)
473
+ try:
474
+ if args.command == "local":
475
+ _run_local(cfg, args)
476
+ elif args.command == "runner":
477
+ _run_client(cfg, "runner", args)
478
+ elif args.command == "wheel":
479
+ _run_client(cfg, "wheel", args)
480
+ elif args.command == "keys":
481
+ _run_keys(cfg, args)
482
+ except SaltApiError as e:
483
+ sys.exit(str(e))
325
484
 
326
485
 
327
486
  if __name__ == "__main__":
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -9,6 +9,7 @@ Project-URL: Issues, https://github.com/sandbox-pokhara/saltapi-cli/issues
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Requires-Python: >=3.11
11
11
  Description-Content-Type: text/markdown
12
+ Requires-Dist: typeguard>=4.5.2
12
13
  Provides-Extra: pre-commit
13
14
  Requires-Dist: pre-commit; extra == "pre-commit"
14
15
 
@@ -52,23 +53,23 @@ certificate verification.
52
53
 
53
54
  ```
54
55
  # Local client — fan out to minions
55
- salt-api-cli local '*' test.ping
56
- salt-api-cli local 'bml*' cmd.run 'whoami'
57
- salt-api-cli local 'bml1' cmd.run 'Get-Date' shell=powershell
56
+ salt local '*' test.ping
57
+ salt local 'bml*' cmd.run 'whoami'
58
+ salt local 'bml1' cmd.run 'Get-Date' shell=powershell
58
59
 
59
60
  # Runner client (master-side: manage.status, jobs.list_jobs, ...)
60
- salt-api-cli runner manage.status
61
- salt-api-cli runner jobs.list_jobs
61
+ salt runner manage.status
62
+ salt runner jobs.list_jobs
62
63
 
63
64
  # Wheel client (master-side, low-level)
64
- salt-api-cli wheel key.list_all
65
+ salt wheel key.list_all
65
66
 
66
67
  # Key management (high-level wrapper around the wheel client)
67
- salt-api-cli keys list
68
- salt-api-cli keys accept <id-or-glob>
69
- salt-api-cli keys accept-all
70
- salt-api-cli keys reject <id-or-glob>
71
- salt-api-cli keys delete <id-or-glob>
68
+ salt keys list
69
+ salt keys accept <id-or-glob>
70
+ salt keys accept-all
71
+ salt keys reject <id-or-glob>
72
+ salt keys delete <id-or-glob>
72
73
  ```
73
74
 
74
75
  Any `key=value` argument is parsed as a kwarg to the salt function;
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ salt = salt_api_cli.cli:main
@@ -1,3 +1,4 @@
1
+ typeguard>=4.5.2
1
2
 
2
3
  [pre-commit]
3
4
  pre-commit
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- salt-api-cli = salt_api_cli.cli:main
File without changes
File without changes