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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.4.0
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
 
@@ -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
- table = Table(box=None, show_header=False, pad_edge=False)
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
- table.add_row(Text(marker, style=style), _state_function(key), ref, detail)
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
- dead: set[str],
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 the unreachable, under a one-line status header. ``n_cells`` is how
359
- 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)."""
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 dead:
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(dead)
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 dead:
381
- bits.append(f"[red]{len(dead)} unreachable[/]")
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], set[str], float] | None:
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 deadline trips. While polling it shows a live
398
- per-minion checklist (spinner -> tick), whose trailing columns come from
399
- ``cells_for(value)`` (``n_cells`` of them). Once the run is done the live
400
- view is cleared and this returns ``(returns, dead, expected, start)`` for
401
- the caller to render or ``None`` if no job started (already reported).
402
- ``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."""
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, or salt-api answered with an error body.
408
- console.print_json(json.dumps(submit))
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) # shrinks as unreachable minions are dropped
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, 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,
427
416
  )
428
417
 
429
- # transient=False keeps the finished checklist on screen above the
430
- # 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
431
423
  with Live(console=console, refresh_per_second=12, transient=False) as live:
432
- prev_done = -1
433
- last_change = start
434
- while True:
435
- # lookup_jid is cumulative: each poll returns every minion that has
436
- # reported so far, so we just keep the latest snapshot.
437
- returns = _lookup_returns(
438
- _first_return(call("runner", fun="jobs.lookup_jid", kwarg={"jid": jid}))
439
- )
440
- done = expected & set(returns)
441
- now = time.monotonic()
442
- if len(done) != prev_done:
443
- prev_done, last_change = len(done), now
444
- live.update(view())
445
-
446
- if not expected - done:
447
- break
448
-
449
- # Stalled? Ask the stragglers whether they're still running the
450
- # job; drop the ones that aren't (down or lost it) so we stop
451
- # waiting on them instead of blocking to the deadline.
452
- if now - last_change > _GATHER_TIMEOUT:
453
- gone = _find_dead(call, jid, expected - done)
454
- if gone:
455
- dead |= gone
456
- expected -= gone
457
- last_change = now # don't re-probe every single poll
458
- live.update(view())
459
- if not expected - done:
460
- break
461
-
462
- if now - start > _POLL_DEADLINE:
463
- break
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 response from: {', '.join(sorted(dead, key=_natural_key))} "
475
- 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[/]"
476
459
  )
477
- if stalled:
460
+ else:
478
461
  console.print(
479
- f"[yellow]still running at the {int(_POLL_DEADLINE)}s deadline: "
480
- f"{', '.join(stalled)}[/]"
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, dead, expected, start = result
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
- _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
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, dead, expected, start = result
637
+ returns, outstanding, start, interrupted = result
655
638
 
656
639
  _print_cmd_result({"return": [returns]})
657
- _print_stragglers(dead, sorted(expected - set(returns) - dead, key=_natural_key))
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: salt-api-cli
3
- Version: 1.4.0
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.0"
File without changes
File without changes
File without changes