rdeploy 0.3.0__tar.gz → 0.4.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,3 +1,19 @@
1
+ ----
2
+ Version 0.4.0, Fri 10 Jul 2026
3
+ ===============================
4
+ - `rdeploy upgrade` now shows live rollout progress while the atomic helm
5
+ upgrade waits: it polls `kubectl get pods` and shows the pod status table
6
+ (prefixed with `[rollout]`), so you can watch pods being created, pulled,
7
+ started and terminated (including helm hook pods such as migration jobs)
8
+ instead of blocking silently. On a terminal the table is pinned to the
9
+ bottom of the screen and updates in place while helm's output scrolls
10
+ above it; on a non-TTY (e.g. CI logs) it prints the table once, then any
11
+ row that changes.
12
+ - Added `--no-progress` to disable the live progress display. The display is
13
+ purely informational and runs concurrently with helm; helm `--atomic`
14
+ remains the sole arbiter of success, failure and rollback, and the watcher
15
+ never affects the command's exit code.
16
+
1
17
  ----
2
18
  Version 0.3.0, Thu 09 Jul 2026
3
19
  ===============================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rdeploy
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: CLI that makes it easy to build and deploy an application to k8s.
5
5
  Author-email: Rehive <info@rehive.com>
6
6
  License: MIT
rdeploy-0.4.0/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.4.0
@@ -3,6 +3,10 @@ import tarfile
3
3
  import zipfile
4
4
  import urllib.request
5
5
  import io
6
+ import shutil
7
+ import threading
8
+ import time
9
+ import subprocess
6
10
 
7
11
  from invoke import task
8
12
  import semver
@@ -370,8 +374,178 @@ def install(ctx, config):
370
374
  echo=True)
371
375
 
372
376
 
377
+ class _RolloutDisplay:
378
+ """Pin a live status region to the bottom of a TTY while other output
379
+ scrolls above it.
380
+
381
+ The cursor arithmetic only stays consistent if every terminal write goes
382
+ through this object, so helm's stdout/stderr are routed here as well (via
383
+ invoke's out_stream/err_stream) and rendered above the pinned region.
384
+ Uses plain ANSI: ESC[nA moves the cursor up n lines, ESC[K clears to the
385
+ end of the line, ESC[J clears to the end of the screen.
386
+ """
387
+
388
+ def __init__(self, stream):
389
+ self._stream = stream
390
+ self._lock = threading.Lock()
391
+ self._region = []
392
+ self._drawn = 0
393
+
394
+ def log(self, text):
395
+ """Write scrolling output (complete lines) above the status region."""
396
+ with self._lock:
397
+ self._render(log_lines=text.splitlines())
398
+
399
+ def update(self, lines):
400
+ """Replace the pinned status region with the given lines."""
401
+ with self._lock:
402
+ self._region = list(lines)
403
+ self._render()
404
+
405
+ def _render(self, log_lines=()):
406
+ # Cursor rests at column 0 just below the region; move to the
407
+ # region's top, write the scrolling lines (overwriting old region
408
+ # rows), redraw the region beneath them, and clear any leftover rows
409
+ # from a previously taller region.
410
+ if self._drawn:
411
+ self._stream.write('\x1b[{n}A'.format(n=self._drawn))
412
+ for line in log_lines:
413
+ self._stream.write(line + '\x1b[K\n')
414
+ # Region lines must not wrap or the cursor-up count breaks, so
415
+ # truncate them to the terminal width. Scrolling lines may wrap
416
+ # freely: they end up above the region either way.
417
+ width = shutil.get_terminal_size((120, 24)).columns
418
+ for line in self._region:
419
+ self._stream.write(line[:width - 1] + '\x1b[K\n')
420
+ self._stream.write('\x1b[J')
421
+ self._stream.flush()
422
+ self._drawn = len(self._region)
423
+
424
+
425
+ class _LogStream(io.TextIOBase):
426
+ """File-like stream for invoke's out_stream/err_stream that forwards
427
+ complete lines to a _RolloutDisplay, buffering partial lines so the
428
+ display's line accounting stays exact."""
429
+
430
+ def __init__(self, display):
431
+ self._display = display
432
+ self._buf = ''
433
+
434
+ def write(self, s):
435
+ self._buf += s
436
+ if '\n' in self._buf:
437
+ complete, self._buf = self._buf.rsplit('\n', 1)
438
+ self._display.log(complete + '\n')
439
+ return len(s)
440
+
441
+ def flush(self):
442
+ pass
443
+
444
+ def drain(self):
445
+ """Emit any trailing partial line (call once the command is done)."""
446
+ if self._buf:
447
+ self._display.log(self._buf + '\n')
448
+ self._buf = ''
449
+
450
+
451
+ def _watch_rollout(stop_event, poll_interval=2, display=None):
452
+ """Show live `kubectl get pods` progress while the atomic helm upgrade
453
+ waits, so you can see pods being created, pulled, started and terminated
454
+ instead of a silent wait.
455
+
456
+ Pods (not deployments) are polled because for single-replica services the
457
+ deployment counters are blind to almost the whole rollout: while the new
458
+ pod is created and pulls its image the row reads 1/1, identical to steady
459
+ state, and only blinks to 2/1 for the second or two between the new pod
460
+ becoming ready and the old one being deleted. Pod rows change visibly for
461
+ the entire rollout (ContainerCreating during the pull, Running,
462
+ Terminating, CrashLoopBackOff on a bad release) and also surface helm
463
+ hook pods such as migration jobs.
464
+
465
+ With a display (stdout is a TTY) the whole pod table is pinned to the
466
+ bottom of the terminal and updated in place every poll, while helm's own
467
+ output scrolls above it. Without one (non-TTY, e.g. CI logs) it prints
468
+ append-only lines: the table once as a baseline, then only rows whose
469
+ READY/STATUS/RESTARTS change (ignoring the ticking AGE and restart-age
470
+ columns).
471
+
472
+ Runs in a background daemon thread and is fully decoupled from the
473
+ upgrade: its output is informational only and its exit codes are ignored.
474
+ Every poll reflects the cluster's current state -- pods created by the
475
+ upgrade itself show up automatically, and there are no long-lived child
476
+ processes to tear down: each poll is one short, bounded `kubectl get`.
477
+
478
+ set_context() has already pointed kubectl at the right context and
479
+ namespace. The whole body is wrapped in a broad except so a broken
480
+ watcher (kubectl missing, permission error) can never raise into the
481
+ main flow or change the exit code.
482
+ """
483
+ last_rows = {}
484
+ header_printed = False
485
+ failures = 0
486
+
487
+ def _note(message):
488
+ line = '[rollout] {message}'.format(message=message)
489
+ if display is not None:
490
+ display.update([])
491
+ display.log(line + '\n')
492
+ else:
493
+ print(line, flush=True)
494
+
495
+ try:
496
+ while not stop_event.is_set():
497
+ status = subprocess.run(['kubectl', 'get', 'pods',
498
+ '--request-timeout=10s'],
499
+ capture_output=True, text=True)
500
+ if status.returncode == 0:
501
+ failures = 0
502
+ lines = status.stdout.splitlines()
503
+ if display is not None:
504
+ # Live region: redraw the whole current table in place.
505
+ region = ['[rollout] {line}'.format(line=line)
506
+ for line in lines]
507
+ if region:
508
+ region.append('[rollout] (live view, updated {ts})'
509
+ .format(ts=time.strftime('%H:%M:%S')))
510
+ display.update(region)
511
+ else:
512
+ # Append-only: baseline table once, then changed rows.
513
+ to_print = []
514
+ if lines and not header_printed:
515
+ header_printed = True
516
+ to_print.append(lines[0])
517
+ for row in lines[1:]:
518
+ fields = row.split()
519
+ if not fields:
520
+ continue
521
+ # Compare only NAME READY STATUS RESTARTS so the
522
+ # ticking AGE and the '(Xm ago)' restart annotation
523
+ # don't reprint an otherwise unchanged row.
524
+ name, key = fields[0], ' '.join(fields[:4])
525
+ if last_rows.get(name) != key:
526
+ last_rows[name] = key
527
+ to_print.append(row)
528
+ for line in to_print:
529
+ print('[rollout] {line}'.format(line=line),
530
+ flush=True)
531
+ else:
532
+ # Tolerate transient API errors, but give up (quietly, with a
533
+ # single note) if kubectl keeps failing.
534
+ failures += 1
535
+ if failures >= 3:
536
+ error = (status.stderr or '').strip().splitlines()
537
+ _note('progress display unavailable: {err}'
538
+ .format(err=error[0] if error else
539
+ 'kubectl exited with code {rc}'
540
+ .format(rc=status.returncode)))
541
+ return
542
+ stop_event.wait(poll_interval)
543
+ except Exception as err:
544
+ _note('progress display unavailable: {err}'.format(err=err))
545
+
546
+
373
547
  @task
374
- def upgrade(ctx, config, tag, atomic=True, timeout='15m'):
548
+ def upgrade(ctx, config, tag, atomic=True, timeout='15m', progress=True):
375
549
  """
376
550
  Upgrades kubernetes deployment
377
551
 
@@ -379,6 +553,15 @@ def upgrade(ctx, config, tag, atomic=True, timeout='15m'):
379
553
  complete and rolls back to the previous release if it fails, and the
380
554
  task exits with an error. Use --no-atomic to restore the old
381
555
  fire-and-forget behaviour, and --timeout to change how long helm waits.
556
+
557
+ While the atomic upgrade waits, a live progress display polls
558
+ `kubectl get pods` and shows the pod status table (prefixed with
559
+ [rollout]), so you can watch pods being created, pulled, started and
560
+ terminated instead of a silent wait. On a terminal the table is pinned
561
+ to the bottom of the screen and updates in place while helm's output
562
+ scrolls above it; on a non-TTY it prints the table once, then any row
563
+ that changes. This is purely informational and never affects the
564
+ result; use --no-progress to disable it.
382
565
  """
383
566
 
384
567
  settings_dict = get_settings()
@@ -398,18 +581,59 @@ def upgrade(ctx, config, tag, atomic=True, timeout='15m'):
398
581
 
399
582
  atomic_flags = ' --atomic --timeout {timeout}'.format(timeout=timeout) if atomic else ''
400
583
 
401
- result = ctx.run('{helm_bin} upgrade {project_name} '
402
- '--values {helm_values_path} '
403
- '--set image.tag={version} '
404
- '--version {helm_chart_version}{atomic_flags} {helm_chart}'
405
- .format(helm_bin=helm_bin,
406
- project_name=config_dict['project_name'],
407
- helm_chart=helm_chart,
408
- helm_values_path=config_dict['helm_values_path'],
409
- version=tag,
410
- helm_chart_version=config_dict['helm_chart_version'],
411
- atomic_flags=atomic_flags),
412
- echo=True, warn=True)
584
+ # Only watch when there is an atomic wait to watch (--no-atomic returns
585
+ # immediately) and progress display is enabled.
586
+ show_progress = atomic and progress
587
+ stop_event = threading.Event()
588
+ watcher = None
589
+ display = None
590
+ run_streams = {}
591
+ if show_progress:
592
+ if sys.stdout.isatty():
593
+ # Live view: the pod table is pinned to the bottom of the
594
+ # terminal and helm's output scrolls above it. Helm's streams
595
+ # must be routed through the display for the cursor arithmetic
596
+ # to hold, so hand invoke our own out/err streams.
597
+ display = _RolloutDisplay(sys.stdout)
598
+ run_streams = {'out_stream': _LogStream(display),
599
+ 'err_stream': _LogStream(display)}
600
+ watcher = threading.Thread(target=_watch_rollout, args=(stop_event,),
601
+ kwargs={'display': display}, daemon=True)
602
+
603
+ helm_command = ('{helm_bin} upgrade {project_name} '
604
+ '--values {helm_values_path} '
605
+ '--set image.tag={version} '
606
+ '--version {helm_chart_version}{atomic_flags} {helm_chart}'
607
+ .format(helm_bin=helm_bin,
608
+ project_name=config_dict['project_name'],
609
+ helm_chart=helm_chart,
610
+ helm_values_path=config_dict['helm_values_path'],
611
+ version=tag,
612
+ helm_chart_version=config_dict['helm_chart_version'],
613
+ atomic_flags=atomic_flags))
614
+
615
+ # The helm call below is the sole source of truth for success/failure/
616
+ # rollback. The watcher runs concurrently and is stopped in finally
617
+ # whether helm succeeds or rolls back; its output never influences result.
618
+ try:
619
+ if watcher is not None:
620
+ watcher.start()
621
+
622
+ if display is not None:
623
+ # invoke's echo writes straight to stdout, which would race with
624
+ # the display's cursor arithmetic; echo through the display
625
+ # instead (same bold formatting as invoke).
626
+ display.log('\033[1;37m{cmd}\033[0m\n'.format(cmd=helm_command))
627
+ result = ctx.run(helm_command, echo=display is None, warn=True,
628
+ **run_streams)
629
+ finally:
630
+ stop_event.set()
631
+ if watcher is not None:
632
+ # A poll in flight finishes within kubectl's --request-timeout,
633
+ # so this join is bounded and leaves no child processes behind.
634
+ watcher.join(timeout=15)
635
+ for stream in run_streams.values():
636
+ stream.drain()
413
637
 
414
638
  if result.failed:
415
639
  if atomic:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rdeploy
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: CLI that makes it easy to build and deploy an application to k8s.
5
5
  Author-email: Rehive <info@rehive.com>
6
6
  License: MIT