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.
- {rdeploy-0.3.0 → rdeploy-0.4.0}/CHANGES.txt +16 -0
- {rdeploy-0.3.0/rdeploy.egg-info → rdeploy-0.4.0}/PKG-INFO +1 -1
- rdeploy-0.4.0/VERSION +1 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy/tasks.py +237 -13
- {rdeploy-0.3.0 → rdeploy-0.4.0/rdeploy.egg-info}/PKG-INFO +1 -1
- rdeploy-0.4.0/tests/test_tasks.py +669 -0
- rdeploy-0.3.0/VERSION +0 -1
- rdeploy-0.3.0/tests/test_tasks.py +0 -344
- {rdeploy-0.3.0 → rdeploy-0.4.0}/LICENSE +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/README.md +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/pyproject.toml +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy/__init__.py +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy/exceptions.py +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy/main.py +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy/utils.py +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy.egg-info/SOURCES.txt +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy.egg-info/dependency_links.txt +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy.egg-info/entry_points.txt +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy.egg-info/requires.txt +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/rdeploy.egg-info/top_level.txt +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/requirements.txt +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/setup.cfg +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/tests/test_exceptions.py +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/tests/test_main.py +0 -0
- {rdeploy-0.3.0 → rdeploy-0.4.0}/tests/test_utils.py +0 -0
|
@@ -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
|
===============================
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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:
|