salt-api-cli 1.4.1__tar.gz → 1.4.3__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.4.1
3
+ Version: 1.4.3
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -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 SaltApiError, split_args
44
+ from salt_api_cli.lowlevel import split_args
45
45
 
46
46
  console = Console()
47
47
 
@@ -177,8 +177,23 @@ def _print_state_return(minion: str, states: dict[str, Any]) -> None:
177
177
  if status == "ok":
178
178
  detail: str | Text = ""
179
179
  elif status == "change":
180
- changed = ", ".join(state.get("changes", {})) or "(changes)"
181
- detail = f"changed: {_short(changed)}"
180
+ changes = state.get("changes", {})
181
+ if "stdout" in changes:
182
+ # cmd.run-style changes: show the command's output itself
183
+ # rather than the fixed pid/retcode/stdout/stderr key list.
184
+ out = (
185
+ str(changes.get("stdout") or "").strip()
186
+ or str(changes.get("stderr") or "").strip()
187
+ )
188
+ # Full output, folded across lines, so nothing is cut off.
189
+ detail = (
190
+ Text(out, no_wrap=False, overflow="fold")
191
+ if out
192
+ else "changed: (no output)"
193
+ )
194
+ else:
195
+ changed = ", ".join(changes) or "(changes)"
196
+ detail = f"changed: {_short(changed)}"
182
197
  elif status == "fail":
183
198
  detail = Text(_short(state.get("comment", ""), 240), style="red")
184
199
  else: # diff / skip
@@ -193,7 +208,12 @@ def _print_state_return(minion: str, states: dict[str, Any]) -> None:
193
208
  fn_w = max((len(fn) for _, fn, _, _ in rows), default=8)
194
209
  ref_w = max((len(ref) for _, _, ref, _ in rows), default=8)
195
210
  nat_w = max(
196
- (len(d.plain if isinstance(d, Text) else d) for _, _, _, d in rows), default=0
211
+ (
212
+ len(line)
213
+ for _, _, _, d in rows
214
+ for line in (d.plain if isinstance(d, Text) else d).splitlines()
215
+ ),
216
+ default=0,
197
217
  )
198
218
  detail_w = min(nat_w, max(20, console.width - 2 - 1 - fn_w - ref_w - 3 * 2))
199
219
 
@@ -242,35 +262,24 @@ def _print_state_result(result: dict[str, Any]) -> None:
242
262
  if not ret:
243
263
  console.print("(no minions responded)")
244
264
  return
245
- for minion in sorted(ret):
265
+ for minion in sorted(ret, key=_natural_key):
246
266
  _print_one_minion(minion, ret[minion])
247
267
 
248
268
 
249
269
  # How often to poll jobs.lookup_jid, and how long to keep waiting overall
250
270
  # before giving up on minions that never reported. Each poll is a fast,
251
271
  # self-contained request, so the proxy/gateway connection cap never bites.
272
+ #
273
+ # We don't probe minion liveness (saltutil.find_job): an empty probe is
274
+ # ambiguous — a busy-but-alive Windows minion mid-highstate can simply fail to
275
+ # answer in time and look identical to a down one, so probing wrongly dropped
276
+ # live minions. Instead we just poll until every targeted minion has returned
277
+ # or _POLL_DEADLINE trips, then render whatever came back. The job keeps
278
+ # running on the minions regardless; results stay fetchable later by jid. Press
279
+ # Ctrl+C to stop waiting early and render the partial results gathered so far.
252
280
  _POLL_INTERVAL = 3.0
253
281
  _POLL_DEADLINE = 1800.0 # 30 minutes (hard backstop)
254
282
 
255
- # Once this many seconds pass with no new minion reporting, probe the
256
- # still-outstanding minions with saltutil.find_job to tell "still running"
257
- # apart from "down / lost the job" — the latter are dropped so we stop
258
- # waiting on them.
259
- #
260
- # The probe passes BOTH a short publish ``timeout`` and a short
261
- # ``gather_job_timeout``. The latter matters most: when a call targets an
262
- # offline minion, the master runs its own internal find_job and waits
263
- # gather_job_timeout (default ~10s on the master) for a reply that never
264
- # comes — so without overriding it, flagging an offline minion costs ~10s+
265
- # no matter how small ``timeout`` is. With both set low the cost drops to a
266
- # few seconds (verified against this master: offline minion flagged in ~3s).
267
- # find_job reports whether a minion is *running the job*, so an online minion
268
- # answers within ``timeout`` and is never wrongly dropped. Instant detection
269
- # would need presence_events on the master (manage.present/alived are empty).
270
- _GATHER_TIMEOUT = 5.0
271
- _FIND_JOB_TIMEOUT = 2.0
272
- _FIND_JOB_GATHER = 2.0
273
-
274
283
 
275
284
  def _first_return(resp: dict[str, Any]) -> Any:
276
285
  """The first element of a salt-api ``return`` list, or ``{}`` if absent."""
@@ -280,38 +289,6 @@ def _first_return(resp: dict[str, Any]) -> Any:
280
289
  return {}
281
290
 
282
291
 
283
- def _find_dead(
284
- call: Callable[..., dict[str, Any]], jid: str, candidates: set[str]
285
- ) -> set[str]:
286
- """Return the candidates that are NOT running ``jid`` (down or lost it).
287
-
288
- Probes only ``candidates`` via the local client + ``saltutil.find_job``
289
- with a short timeout. A minion actively running the job answers with a
290
- non-empty dict naming the jid; one that's down never answers, and one
291
- that's up but no longer running it answers empty — both mean it won't
292
- return, so it's reported dead. A failed probe reports nobody dead (we'd
293
- rather wait than wrongly drop a live minion)."""
294
- if not candidates:
295
- return set()
296
- try:
297
- resp = call(
298
- "local",
299
- tgt=sorted(candidates),
300
- tgt_type="list",
301
- fun="saltutil.find_job",
302
- arg=[jid],
303
- timeout=_FIND_JOB_TIMEOUT,
304
- gather_job_timeout=_FIND_JOB_GATHER,
305
- )
306
- except SaltApiError:
307
- return set()
308
- ret = _first_return(resp)
309
- if not isinstance(ret, dict):
310
- return set()
311
- running = cast("dict[str, Any]", ret)
312
- return {m for m in candidates if not running.get(m)}
313
-
314
-
315
292
  def _lookup_returns(raw: Any) -> dict[str, Any]:
316
293
  """Pull the ``{minion: state_return}`` map out of a jobs.lookup_jid reply.
317
294
 
@@ -363,7 +340,7 @@ def _live_view(
363
340
  targeted: list[str],
364
341
  returns: dict[str, Any],
365
342
  done: set[str],
366
- dead: set[str],
343
+ missing: set[str],
367
344
  spinner: Spinner,
368
345
  *,
369
346
  n_cells: int,
@@ -371,8 +348,11 @@ def _live_view(
371
348
  ) -> Group:
372
349
  """A live checklist: a tick for finished minions (with ``cells_for`` of
373
350
  their reply in aligned columns), a spinner for the ones still running, an x
374
- for the unreachable, under a one-line status header. ``n_cells`` is how
375
- many trailing columns ``cells_for`` produces (so blank rows stay aligned)."""
351
+ for those that never reported, under a one-line status header. ``missing``
352
+ is only populated in the final frame (after the deadline or a Ctrl+C); while
353
+ polling it's empty, so still-pending minions show a spinner. ``n_cells`` is
354
+ how many trailing columns ``cells_for`` produces (so blank rows stay
355
+ aligned)."""
376
356
  blanks = [Text("")] * n_cells
377
357
  grid = Table.grid(padding=(0, 1))
378
358
  grid.add_column(no_wrap=True) # marker
@@ -380,7 +360,7 @@ def _live_view(
380
360
  for _ in range(n_cells): # per-command trailing columns
381
361
  grid.add_column(no_wrap=True, justify="left")
382
362
  for minion in targeted:
383
- if minion in dead:
363
+ if minion in missing:
384
364
  grid.add_row(Text("X", style="red"), Text(minion, style="dim"), *blanks)
385
365
  elif minion in done:
386
366
  grid.add_row(
@@ -389,12 +369,12 @@ def _live_view(
389
369
  else:
390
370
  grid.add_row(spinner, Text(minion, style="dim"), *blanks)
391
371
 
392
- pending = len(targeted) - len(done) - len(dead)
372
+ pending = len(targeted) - len(done) - len(missing)
393
373
  bits = [f"{len(done)}/{len(targeted)} done"]
394
374
  if pending:
395
375
  bits.append(f"{pending} running")
396
- if dead:
397
- bits.append(f"[red]{len(dead)} unreachable[/]")
376
+ if missing:
377
+ bits.append(f"[red]{len(missing)} no response[/]")
398
378
  header = Text.from_markup(f"[dim]{' '.join(bits)}[/]")
399
379
  return Group(header, grid)
400
380
 
@@ -405,17 +385,19 @@ def _stream_job(
405
385
  *,
406
386
  n_cells: int,
407
387
  cells_for: Callable[[Any], list[Text]],
408
- ) -> tuple[dict[str, Any], set[str], set[str], float] | None:
388
+ ) -> tuple[dict[str, Any], set[str], float, bool] | None:
409
389
  """Fire a job async, show a live checklist, and return its raw results.
410
390
 
411
391
  Submits ``payload`` via the ``local_async`` client (returns a job id at
412
392
  once), then polls ``runner jobs.lookup_jid`` until every targeted minion
413
- has returned or the deadline trips. While polling it shows a live
414
- per-minion checklist (spinner -> tick), whose trailing columns come from
415
- ``cells_for(value)`` (``n_cells`` of them). Once the run is done the live
416
- view is cleared and this returns ``(returns, dead, expected, start)`` for
417
- the caller to render or ``None`` if no job started (already reported).
418
- ``call(name, **kw)`` invokes the named salt-api client."""
393
+ has returned, the deadline trips, or the user hits Ctrl+C. While polling it
394
+ shows a live per-minion checklist (spinner -> tick), whose trailing columns
395
+ come from ``cells_for(value)`` (``n_cells`` of them). In every case it then
396
+ renders the final checklist frame and returns ``(returns, outstanding,
397
+ start, interrupted)`` — ``outstanding`` being the targeted minions that
398
+ never reported — for the caller to render, or ``None`` if no job started
399
+ (already reported). ``call(name, **kw)`` invokes the named salt-api
400
+ client."""
419
401
  submit = call("local_async", **payload)
420
402
  info: Any = _first_return(submit)
421
403
  jid = info.get("jid")
@@ -435,71 +417,70 @@ def _stream_job(
435
417
  console.print("(no minions matched the target)")
436
418
  return None
437
419
 
438
- expected = set(targeted) # shrinks as unreachable minions are dropped
420
+ expected = set(targeted)
439
421
  console.print(f"[dim]job {jid} -> {len(targeted)} minion(s)[/]")
440
422
  start = time.monotonic()
441
423
  returns: dict[str, Any] = {}
442
- dead: set[str] = set() # probed and confirmed not running the job
443
424
  spinner = Spinner("dots", style="cyan")
444
425
 
445
- def view() -> Group:
426
+ def view(missing: set[str] | None = None) -> Group:
446
427
  done = expected & set(returns)
447
428
  return _live_view(
448
- targeted, returns, done, dead, spinner, n_cells=n_cells, cells_for=cells_for
429
+ targeted,
430
+ returns,
431
+ done,
432
+ missing or set(),
433
+ spinner,
434
+ n_cells=n_cells,
435
+ cells_for=cells_for,
449
436
  )
450
437
 
451
- # transient=False keeps the finished checklist on screen above the
452
- # rendered tables, as a persistent at-a-glance record of the run.
438
+ # Poll lookup_jid until everyone's back or the deadline trips; Ctrl+C stops
439
+ # waiting early. The job keeps running on the minions either way — we just
440
+ # stop watching and render whatever was gathered. transient=False keeps the
441
+ # finished checklist on screen above the rendered tables.
442
+ interrupted = False
453
443
  with Live(console=console, refresh_per_second=12, transient=False) as live:
454
- prev_done = -1
455
- last_change = start
456
- while True:
457
- # lookup_jid is cumulative: each poll returns every minion that has
458
- # reported so far, so we just keep the latest snapshot.
459
- returns = _lookup_returns(
460
- _first_return(call("runner", fun="jobs.lookup_jid", kwarg={"jid": jid}))
461
- )
462
- done = expected & set(returns)
463
- now = time.monotonic()
464
- if len(done) != prev_done:
465
- prev_done, last_change = len(done), now
466
- live.update(view())
467
-
468
- if not expected - done:
469
- break
470
-
471
- # Stalled? Ask the stragglers whether they're still running the
472
- # job; drop the ones that aren't (down or lost it) so we stop
473
- # waiting on them instead of blocking to the deadline.
474
- if now - last_change > _GATHER_TIMEOUT:
475
- gone = _find_dead(call, jid, expected - done)
476
- if gone:
477
- dead |= gone
478
- expected -= gone
479
- last_change = now # don't re-probe every single poll
480
- live.update(view())
481
- if not expected - done:
482
- break
483
-
484
- if now - start > _POLL_DEADLINE:
485
- break
486
- time.sleep(_POLL_INTERVAL)
487
-
488
- return returns, dead, expected, start
489
-
490
-
491
- def _print_stragglers(dead: set[str], stalled: list[str]) -> None:
492
- """The shared trailer for a streamed job: who never answered and who was
493
- still running when the deadline tripped."""
494
- if dead:
444
+ try:
445
+ while True:
446
+ # lookup_jid is cumulative: each poll returns every minion that
447
+ # has reported so far, so we just keep the latest snapshot.
448
+ returns = _lookup_returns(
449
+ _first_return(
450
+ call("runner", fun="jobs.lookup_jid", kwarg={"jid": jid})
451
+ )
452
+ )
453
+ live.update(view())
454
+ if not expected - set(returns):
455
+ break
456
+ if time.monotonic() - start > _POLL_DEADLINE:
457
+ break
458
+ time.sleep(_POLL_INTERVAL)
459
+ except KeyboardInterrupt:
460
+ interrupted = True
461
+ # Final frame: mark whoever never reported so the persisted checklist
462
+ # reflects the true end state rather than a frozen spinner.
463
+ outstanding = expected - set(returns)
464
+ live.update(view(outstanding))
465
+
466
+ return returns, expected - set(returns), start, interrupted
467
+
468
+
469
+ def _print_outstanding(outstanding: set[str], interrupted: bool) -> None:
470
+ """Trailer naming the minions that hadn't reported when we stopped waiting
471
+ because the user interrupted, or the deadline tripped."""
472
+ if not outstanding:
473
+ return
474
+ names = ", ".join(sorted(outstanding, key=_natural_key))
475
+ if interrupted:
495
476
  console.print(
496
- f"[yellow]no response from: {', '.join(sorted(dead, key=_natural_key))} "
497
- f"(down, or no longer running the job)[/]"
477
+ f"[yellow]stopped waiting (Ctrl+C); no result yet from: {names} "
478
+ f"- the job may still be running on them[/]"
498
479
  )
499
- if stalled:
480
+ else:
500
481
  console.print(
501
- f"[yellow]still running at the {int(_POLL_DEADLINE)}s deadline: "
502
- f"{', '.join(stalled)}[/]"
482
+ f"[yellow]no result from: {names} within the "
483
+ f"{int(_POLL_DEADLINE)}s deadline (still running, or down)[/]"
503
484
  )
504
485
 
505
486
 
@@ -509,11 +490,11 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
509
490
  result = _stream_job(call, payload, n_cells=5, cells_for=_state_cells)
510
491
  if result is None:
511
492
  return
512
- returns, dead, expected, start = result
493
+ returns, outstanding, start, interrupted = result
513
494
 
514
495
  # Live view cleared — render the coloured tables, one block per minion.
515
496
  _print_state_result({"return": [returns]})
516
- _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
497
+ _print_outstanding(outstanding, interrupted)
517
498
 
518
499
  # Fleet-wide summary: totals across all minions + wall-clock elapsed.
519
500
  totals, n = _grand_totals(returns)
@@ -673,10 +654,10 @@ def _stream_cmd(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) ->
673
654
  result = _stream_job(call, payload, n_cells=1, cells_for=_cmd_cells)
674
655
  if result is None:
675
656
  return
676
- returns, dead, expected, start = result
657
+ returns, outstanding, start, interrupted = result
677
658
 
678
659
  _print_cmd_result({"return": [returns]})
679
- _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
660
+ _print_outstanding(outstanding, interrupted)
680
661
 
681
662
  n = len(returns)
682
663
  if n:
@@ -0,0 +1 @@
1
+ __version__ = "1.4.3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: CLI to access salt-api
5
5
  Author-email: Pradish Bijukchhe <pradish@sandbox.com.np>
6
6
  License-Expression: MIT
@@ -1 +0,0 @@
1
- __version__ = "1.4.1"
File without changes
File without changes
File without changes