salt-api-cli 1.4.1__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.
@@ -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.2
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
 
@@ -249,28 +249,17 @@ def _print_state_result(result: dict[str, Any]) -> None:
249
249
  # How often to poll jobs.lookup_jid, and how long to keep waiting overall
250
250
  # before giving up on minions that never reported. Each poll is a fast,
251
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.
252
260
  _POLL_INTERVAL = 3.0
253
261
  _POLL_DEADLINE = 1800.0 # 30 minutes (hard backstop)
254
262
 
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
263
 
275
264
  def _first_return(resp: dict[str, Any]) -> Any:
276
265
  """The first element of a salt-api ``return`` list, or ``{}`` if absent."""
@@ -280,38 +269,6 @@ def _first_return(resp: dict[str, Any]) -> Any:
280
269
  return {}
281
270
 
282
271
 
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
272
  def _lookup_returns(raw: Any) -> dict[str, Any]:
316
273
  """Pull the ``{minion: state_return}`` map out of a jobs.lookup_jid reply.
317
274
 
@@ -363,7 +320,7 @@ def _live_view(
363
320
  targeted: list[str],
364
321
  returns: dict[str, Any],
365
322
  done: set[str],
366
- dead: set[str],
323
+ missing: set[str],
367
324
  spinner: Spinner,
368
325
  *,
369
326
  n_cells: int,
@@ -371,8 +328,11 @@ def _live_view(
371
328
  ) -> Group:
372
329
  """A live checklist: a tick for finished minions (with ``cells_for`` of
373
330
  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)."""
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)."""
376
336
  blanks = [Text("")] * n_cells
377
337
  grid = Table.grid(padding=(0, 1))
378
338
  grid.add_column(no_wrap=True) # marker
@@ -380,7 +340,7 @@ def _live_view(
380
340
  for _ in range(n_cells): # per-command trailing columns
381
341
  grid.add_column(no_wrap=True, justify="left")
382
342
  for minion in targeted:
383
- if minion in dead:
343
+ if minion in missing:
384
344
  grid.add_row(Text("X", style="red"), Text(minion, style="dim"), *blanks)
385
345
  elif minion in done:
386
346
  grid.add_row(
@@ -389,12 +349,12 @@ def _live_view(
389
349
  else:
390
350
  grid.add_row(spinner, Text(minion, style="dim"), *blanks)
391
351
 
392
- pending = len(targeted) - len(done) - len(dead)
352
+ pending = len(targeted) - len(done) - len(missing)
393
353
  bits = [f"{len(done)}/{len(targeted)} done"]
394
354
  if pending:
395
355
  bits.append(f"{pending} running")
396
- if dead:
397
- bits.append(f"[red]{len(dead)} unreachable[/]")
356
+ if missing:
357
+ bits.append(f"[red]{len(missing)} no response[/]")
398
358
  header = Text.from_markup(f"[dim]{' '.join(bits)}[/]")
399
359
  return Group(header, grid)
400
360
 
@@ -405,17 +365,19 @@ def _stream_job(
405
365
  *,
406
366
  n_cells: int,
407
367
  cells_for: Callable[[Any], list[Text]],
408
- ) -> tuple[dict[str, Any], set[str], set[str], float] | None:
368
+ ) -> tuple[dict[str, Any], set[str], float, bool] | None:
409
369
  """Fire a job async, show a live checklist, and return its raw results.
410
370
 
411
371
  Submits ``payload`` via the ``local_async`` client (returns a job id at
412
372
  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."""
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."""
419
381
  submit = call("local_async", **payload)
420
382
  info: Any = _first_return(submit)
421
383
  jid = info.get("jid")
@@ -435,71 +397,70 @@ def _stream_job(
435
397
  console.print("(no minions matched the target)")
436
398
  return None
437
399
 
438
- expected = set(targeted) # shrinks as unreachable minions are dropped
400
+ expected = set(targeted)
439
401
  console.print(f"[dim]job {jid} -> {len(targeted)} minion(s)[/]")
440
402
  start = time.monotonic()
441
403
  returns: dict[str, Any] = {}
442
- dead: set[str] = set() # probed and confirmed not running the job
443
404
  spinner = Spinner("dots", style="cyan")
444
405
 
445
- def view() -> Group:
406
+ def view(missing: set[str] | None = None) -> Group:
446
407
  done = expected & set(returns)
447
408
  return _live_view(
448
- targeted, returns, done, dead, spinner, n_cells=n_cells, cells_for=cells_for
409
+ targeted,
410
+ returns,
411
+ done,
412
+ missing or set(),
413
+ spinner,
414
+ n_cells=n_cells,
415
+ cells_for=cells_for,
449
416
  )
450
417
 
451
- # transient=False keeps the finished checklist on screen above the
452
- # rendered tables, as a persistent at-a-glance record of the run.
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
453
423
  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:
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:
495
456
  console.print(
496
- f"[yellow]no response from: {', '.join(sorted(dead, key=_natural_key))} "
497
- f"(down, or no longer running the job)[/]"
457
+ f"[yellow]stopped waiting (Ctrl+C); no result yet from: {names} "
458
+ f"- the job may still be running on them[/]"
498
459
  )
499
- if stalled:
460
+ else:
500
461
  console.print(
501
- f"[yellow]still running at the {int(_POLL_DEADLINE)}s deadline: "
502
- f"{', '.join(stalled)}[/]"
462
+ f"[yellow]no result from: {names} within the "
463
+ f"{int(_POLL_DEADLINE)}s deadline (still running, or down)[/]"
503
464
  )
504
465
 
505
466
 
@@ -509,11 +470,11 @@ def _stream_state(call: Callable[..., dict[str, Any]], payload: dict[str, Any])
509
470
  result = _stream_job(call, payload, n_cells=5, cells_for=_state_cells)
510
471
  if result is None:
511
472
  return
512
- returns, dead, expected, start = result
473
+ returns, outstanding, start, interrupted = result
513
474
 
514
475
  # Live view cleared — render the coloured tables, one block per minion.
515
476
  _print_state_result({"return": [returns]})
516
- _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
477
+ _print_outstanding(outstanding, interrupted)
517
478
 
518
479
  # Fleet-wide summary: totals across all minions + wall-clock elapsed.
519
480
  totals, n = _grand_totals(returns)
@@ -673,10 +634,10 @@ def _stream_cmd(call: Callable[..., dict[str, Any]], payload: dict[str, Any]) ->
673
634
  result = _stream_job(call, payload, n_cells=1, cells_for=_cmd_cells)
674
635
  if result is None:
675
636
  return
676
- returns, dead, expected, start = result
637
+ returns, outstanding, start, interrupted = result
677
638
 
678
639
  _print_cmd_result({"return": [returns]})
679
- _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
640
+ _print_outstanding(outstanding, interrupted)
680
641
 
681
642
  n = len(returns)
682
643
  if n:
@@ -0,0 +1 @@
1
+ __version__ = "1.4.2"
@@ -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.2
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