salt-api-cli 1.4.0__tar.gz → 1.4.2__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.4.0/salt_api_cli.egg-info → salt_api_cli-1.4.2}/PKG-INFO +1 -1
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli/highlevel.py +114 -131
- salt_api_cli-1.4.2/salt_api_cli/version.py +1 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2/salt_api_cli.egg-info}/PKG-INFO +1 -1
- salt_api_cli-1.4.0/salt_api_cli/version.py +0 -1
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/MANIFEST.in +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/README.md +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/pyproject.toml +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli/__init__.py +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli/__main__.py +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli/cli.py +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli/lowlevel.py +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli/py.typed +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli.egg-info/SOURCES.txt +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli.egg-info/dependency_links.txt +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli.egg-info/entry_points.txt +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli.egg-info/requires.txt +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/salt_api_cli.egg-info/top_level.txt +0 -0
- {salt_api_cli-1.4.0 → salt_api_cli-1.4.2}/setup.cfg +0 -0
|
@@ -41,7 +41,7 @@ from rich.spinner import Spinner
|
|
|
41
41
|
from rich.table import Table
|
|
42
42
|
from rich.text import Text
|
|
43
43
|
|
|
44
|
-
from salt_api_cli.lowlevel import
|
|
44
|
+
from salt_api_cli.lowlevel import split_args
|
|
45
45
|
|
|
46
46
|
console = Console()
|
|
47
47
|
|
|
@@ -169,12 +169,7 @@ def _print_state_return(minion: str, states: dict[str, Any]) -> None:
|
|
|
169
169
|
"""Render one minion's state run: header, a table of states, summary."""
|
|
170
170
|
ordered = sorted(states.items(), key=lambda kv: kv[1].get("__run_num__", 1 << 30))
|
|
171
171
|
|
|
172
|
-
|
|
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
|
-
|
|
172
|
+
rows: list[tuple[Text, str, str, str | Text]] = []
|
|
178
173
|
for key, state in ordered:
|
|
179
174
|
status = _state_status(state)
|
|
180
175
|
marker, style = _STATUS_STYLE[status]
|
|
@@ -188,7 +183,28 @@ def _print_state_return(minion: str, states: dict[str, Any]) -> None:
|
|
|
188
183
|
detail = Text(_short(state.get("comment", ""), 240), style="red")
|
|
189
184
|
else: # diff / skip
|
|
190
185
|
detail = _short(state.get("comment", ""))
|
|
191
|
-
|
|
186
|
+
rows.append((Text(marker, style=style), _state_function(key), ref, detail))
|
|
187
|
+
|
|
188
|
+
# Pin the detail column to whatever width is left so rich shrinks *it*
|
|
189
|
+
# (ellipsis) rather than collapsing the short marker/function/ref columns
|
|
190
|
+
# to nothing on a narrow terminal. Width budget: 2-space left Padding +
|
|
191
|
+
# 1-char marker + the natural function/ref widths + three 2-space column
|
|
192
|
+
# gaps (pad_edge=False). Floor at 20 so detail never vanishes outright.
|
|
193
|
+
fn_w = max((len(fn) for _, fn, _, _ in rows), default=8)
|
|
194
|
+
ref_w = max((len(ref) for _, _, ref, _ in rows), default=8)
|
|
195
|
+
nat_w = max(
|
|
196
|
+
(len(d.plain if isinstance(d, Text) else d) for _, _, _, d in rows), default=0
|
|
197
|
+
)
|
|
198
|
+
detail_w = min(nat_w, max(20, console.width - 2 - 1 - fn_w - ref_w - 3 * 2))
|
|
199
|
+
|
|
200
|
+
table = Table(box=None, show_header=False, pad_edge=False)
|
|
201
|
+
table.add_column("marker", no_wrap=True)
|
|
202
|
+
table.add_column("function", style="cyan", no_wrap=True)
|
|
203
|
+
table.add_column("ref", style="dim", no_wrap=True)
|
|
204
|
+
table.add_column("detail", no_wrap=True, overflow="ellipsis", width=detail_w)
|
|
205
|
+
|
|
206
|
+
for row in rows:
|
|
207
|
+
table.add_row(*row)
|
|
192
208
|
|
|
193
209
|
counts, total_ms = _count_states(states)
|
|
194
210
|
console.print(Text(minion, style="bold"))
|
|
@@ -233,28 +249,17 @@ def _print_state_result(result: dict[str, Any]) -> None:
|
|
|
233
249
|
# How often to poll jobs.lookup_jid, and how long to keep waiting overall
|
|
234
250
|
# before giving up on minions that never reported. Each poll is a fast,
|
|
235
251
|
# self-contained request, so the proxy/gateway connection cap never bites.
|
|
252
|
+
#
|
|
253
|
+
# We don't probe minion liveness (saltutil.find_job): an empty probe is
|
|
254
|
+
# ambiguous — a busy-but-alive Windows minion mid-highstate can simply fail to
|
|
255
|
+
# answer in time and look identical to a down one, so probing wrongly dropped
|
|
256
|
+
# live minions. Instead we just poll until every targeted minion has returned
|
|
257
|
+
# or _POLL_DEADLINE trips, then render whatever came back. The job keeps
|
|
258
|
+
# running on the minions regardless; results stay fetchable later by jid. Press
|
|
259
|
+
# Ctrl+C to stop waiting early and render the partial results gathered so far.
|
|
236
260
|
_POLL_INTERVAL = 3.0
|
|
237
261
|
_POLL_DEADLINE = 1800.0 # 30 minutes (hard backstop)
|
|
238
262
|
|
|
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
263
|
|
|
259
264
|
def _first_return(resp: dict[str, Any]) -> Any:
|
|
260
265
|
"""The first element of a salt-api ``return`` list, or ``{}`` if absent."""
|
|
@@ -264,38 +269,6 @@ def _first_return(resp: dict[str, Any]) -> Any:
|
|
|
264
269
|
return {}
|
|
265
270
|
|
|
266
271
|
|
|
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
272
|
def _lookup_returns(raw: Any) -> dict[str, Any]:
|
|
300
273
|
"""Pull the ``{minion: state_return}`` map out of a jobs.lookup_jid reply.
|
|
301
274
|
|
|
@@ -347,7 +320,7 @@ def _live_view(
|
|
|
347
320
|
targeted: list[str],
|
|
348
321
|
returns: dict[str, Any],
|
|
349
322
|
done: set[str],
|
|
350
|
-
|
|
323
|
+
missing: set[str],
|
|
351
324
|
spinner: Spinner,
|
|
352
325
|
*,
|
|
353
326
|
n_cells: int,
|
|
@@ -355,8 +328,11 @@ def _live_view(
|
|
|
355
328
|
) -> Group:
|
|
356
329
|
"""A live checklist: a tick for finished minions (with ``cells_for`` of
|
|
357
330
|
their reply in aligned columns), a spinner for the ones still running, an x
|
|
358
|
-
for
|
|
359
|
-
|
|
331
|
+
for those that never reported, under a one-line status header. ``missing``
|
|
332
|
+
is only populated in the final frame (after the deadline or a Ctrl+C); while
|
|
333
|
+
polling it's empty, so still-pending minions show a spinner. ``n_cells`` is
|
|
334
|
+
how many trailing columns ``cells_for`` produces (so blank rows stay
|
|
335
|
+
aligned)."""
|
|
360
336
|
blanks = [Text("")] * n_cells
|
|
361
337
|
grid = Table.grid(padding=(0, 1))
|
|
362
338
|
grid.add_column(no_wrap=True) # marker
|
|
@@ -364,7 +340,7 @@ def _live_view(
|
|
|
364
340
|
for _ in range(n_cells): # per-command trailing columns
|
|
365
341
|
grid.add_column(no_wrap=True, justify="left")
|
|
366
342
|
for minion in targeted:
|
|
367
|
-
if minion in
|
|
343
|
+
if minion in missing:
|
|
368
344
|
grid.add_row(Text("X", style="red"), Text(minion, style="dim"), *blanks)
|
|
369
345
|
elif minion in done:
|
|
370
346
|
grid.add_row(
|
|
@@ -373,12 +349,12 @@ def _live_view(
|
|
|
373
349
|
else:
|
|
374
350
|
grid.add_row(spinner, Text(minion, style="dim"), *blanks)
|
|
375
351
|
|
|
376
|
-
pending = len(targeted) - len(done) - len(
|
|
352
|
+
pending = len(targeted) - len(done) - len(missing)
|
|
377
353
|
bits = [f"{len(done)}/{len(targeted)} done"]
|
|
378
354
|
if pending:
|
|
379
355
|
bits.append(f"{pending} running")
|
|
380
|
-
if
|
|
381
|
-
bits.append(f"[red]{len(
|
|
356
|
+
if missing:
|
|
357
|
+
bits.append(f"[red]{len(missing)} no response[/]")
|
|
382
358
|
header = Text.from_markup(f"[dim]{' '.join(bits)}[/]")
|
|
383
359
|
return Group(header, grid)
|
|
384
360
|
|
|
@@ -389,23 +365,31 @@ def _stream_job(
|
|
|
389
365
|
*,
|
|
390
366
|
n_cells: int,
|
|
391
367
|
cells_for: Callable[[Any], list[Text]],
|
|
392
|
-
) -> tuple[dict[str, Any], set[str],
|
|
368
|
+
) -> tuple[dict[str, Any], set[str], float, bool] | None:
|
|
393
369
|
"""Fire a job async, show a live checklist, and return its raw results.
|
|
394
370
|
|
|
395
371
|
Submits ``payload`` via the ``local_async`` client (returns a job id at
|
|
396
372
|
once), then polls ``runner jobs.lookup_jid`` until every targeted minion
|
|
397
|
-
has returned or the
|
|
398
|
-
per-minion checklist (spinner -> tick), whose trailing columns
|
|
399
|
-
``cells_for(value)`` (``n_cells`` of them).
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
373
|
+
has returned, the deadline trips, or the user hits Ctrl+C. While polling it
|
|
374
|
+
shows a live per-minion checklist (spinner -> tick), whose trailing columns
|
|
375
|
+
come from ``cells_for(value)`` (``n_cells`` of them). In every case it then
|
|
376
|
+
renders the final checklist frame and returns ``(returns, outstanding,
|
|
377
|
+
start, interrupted)`` — ``outstanding`` being the targeted minions that
|
|
378
|
+
never reported — for the caller to render, or ``None`` if no job started
|
|
379
|
+
(already reported). ``call(name, **kw)`` invokes the named salt-api
|
|
380
|
+
client."""
|
|
403
381
|
submit = call("local_async", **payload)
|
|
404
382
|
info: Any = _first_return(submit)
|
|
405
383
|
jid = info.get("jid")
|
|
406
384
|
if not jid:
|
|
407
|
-
# No job id: nothing matched
|
|
408
|
-
|
|
385
|
+
# No job id: either nothing matched (salt-api hands back an empty
|
|
386
|
+
# body, e.g. {"return": [{}]}) or it answered with an error body. An
|
|
387
|
+
# empty info means no minions matched — say so plainly; reserve the
|
|
388
|
+
# raw JSON dump for an actual error worth showing verbatim.
|
|
389
|
+
if not info:
|
|
390
|
+
console.print("(no minions matched the target)")
|
|
391
|
+
else:
|
|
392
|
+
console.print_json(json.dumps(submit))
|
|
409
393
|
return None
|
|
410
394
|
|
|
411
395
|
targeted = sorted(info.get("minions") or [], key=_natural_key)
|
|
@@ -413,71 +397,70 @@ def _stream_job(
|
|
|
413
397
|
console.print("(no minions matched the target)")
|
|
414
398
|
return None
|
|
415
399
|
|
|
416
|
-
expected = set(targeted)
|
|
400
|
+
expected = set(targeted)
|
|
417
401
|
console.print(f"[dim]job {jid} -> {len(targeted)} minion(s)[/]")
|
|
418
402
|
start = time.monotonic()
|
|
419
403
|
returns: dict[str, Any] = {}
|
|
420
|
-
dead: set[str] = set() # probed and confirmed not running the job
|
|
421
404
|
spinner = Spinner("dots", style="cyan")
|
|
422
405
|
|
|
423
|
-
def view() -> Group:
|
|
406
|
+
def view(missing: set[str] | None = None) -> Group:
|
|
424
407
|
done = expected & set(returns)
|
|
425
408
|
return _live_view(
|
|
426
|
-
targeted,
|
|
409
|
+
targeted,
|
|
410
|
+
returns,
|
|
411
|
+
done,
|
|
412
|
+
missing or set(),
|
|
413
|
+
spinner,
|
|
414
|
+
n_cells=n_cells,
|
|
415
|
+
cells_for=cells_for,
|
|
427
416
|
)
|
|
428
417
|
|
|
429
|
-
#
|
|
430
|
-
#
|
|
418
|
+
# Poll lookup_jid until everyone's back or the deadline trips; Ctrl+C stops
|
|
419
|
+
# waiting early. The job keeps running on the minions either way — we just
|
|
420
|
+
# stop watching and render whatever was gathered. transient=False keeps the
|
|
421
|
+
# finished checklist on screen above the rendered tables.
|
|
422
|
+
interrupted = False
|
|
431
423
|
with Live(console=console, refresh_per_second=12, transient=False) as live:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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:
|
|
424
|
+
try:
|
|
425
|
+
while True:
|
|
426
|
+
# lookup_jid is cumulative: each poll returns every minion that
|
|
427
|
+
# has reported so far, so we just keep the latest snapshot.
|
|
428
|
+
returns = _lookup_returns(
|
|
429
|
+
_first_return(
|
|
430
|
+
call("runner", fun="jobs.lookup_jid", kwarg={"jid": jid})
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
live.update(view())
|
|
434
|
+
if not expected - set(returns):
|
|
435
|
+
break
|
|
436
|
+
if time.monotonic() - start > _POLL_DEADLINE:
|
|
437
|
+
break
|
|
438
|
+
time.sleep(_POLL_INTERVAL)
|
|
439
|
+
except KeyboardInterrupt:
|
|
440
|
+
interrupted = True
|
|
441
|
+
# Final frame: mark whoever never reported so the persisted checklist
|
|
442
|
+
# reflects the true end state rather than a frozen spinner.
|
|
443
|
+
outstanding = expected - set(returns)
|
|
444
|
+
live.update(view(outstanding))
|
|
445
|
+
|
|
446
|
+
return returns, expected - set(returns), start, interrupted
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _print_outstanding(outstanding: set[str], interrupted: bool) -> None:
|
|
450
|
+
"""Trailer naming the minions that hadn't reported when we stopped waiting
|
|
451
|
+
— because the user interrupted, or the deadline tripped."""
|
|
452
|
+
if not outstanding:
|
|
453
|
+
return
|
|
454
|
+
names = ", ".join(sorted(outstanding, key=_natural_key))
|
|
455
|
+
if interrupted:
|
|
473
456
|
console.print(
|
|
474
|
-
f"[yellow]no
|
|
475
|
-
f"
|
|
457
|
+
f"[yellow]stopped waiting (Ctrl+C); no result yet from: {names} "
|
|
458
|
+
f"- the job may still be running on them[/]"
|
|
476
459
|
)
|
|
477
|
-
|
|
460
|
+
else:
|
|
478
461
|
console.print(
|
|
479
|
-
f"[yellow]
|
|
480
|
-
f"{
|
|
462
|
+
f"[yellow]no result from: {names} within the "
|
|
463
|
+
f"{int(_POLL_DEADLINE)}s deadline (still running, or down)[/]"
|
|
481
464
|
)
|
|
482
465
|
|
|
483
466
|
|
|
@@ -487,11 +470,11 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
|
|
|
487
470
|
result = _stream_job(call, payload, n_cells=5, cells_for=_state_cells)
|
|
488
471
|
if result is None:
|
|
489
472
|
return
|
|
490
|
-
returns,
|
|
473
|
+
returns, outstanding, start, interrupted = result
|
|
491
474
|
|
|
492
475
|
# Live view cleared — render the coloured tables, one block per minion.
|
|
493
476
|
_print_state_result({"return": [returns]})
|
|
494
|
-
|
|
477
|
+
_print_outstanding(outstanding, interrupted)
|
|
495
478
|
|
|
496
479
|
# Fleet-wide summary: totals across all minions + wall-clock elapsed.
|
|
497
480
|
totals, n = _grand_totals(returns)
|
|
@@ -651,10 +634,10 @@ def _stream_cmd(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) ->
|
|
|
651
634
|
result = _stream_job(call, payload, n_cells=1, cells_for=_cmd_cells)
|
|
652
635
|
if result is None:
|
|
653
636
|
return
|
|
654
|
-
returns,
|
|
637
|
+
returns, outstanding, start, interrupted = result
|
|
655
638
|
|
|
656
639
|
_print_cmd_result({"return": [returns]})
|
|
657
|
-
|
|
640
|
+
_print_outstanding(outstanding, interrupted)
|
|
658
641
|
|
|
659
642
|
n = len(returns)
|
|
660
643
|
if n:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.2"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.4.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
|
|
File without changes
|
|
File without changes
|