docker-volume-toolkit 1.2.2__py3-none-any.whl

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.
migrate_volumes.py ADDED
@@ -0,0 +1,1699 @@
1
+ #!/usr/bin/env -S uv run --quiet --script
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = ["rich>=13", "textual>=0.80"]
5
+ # ///
6
+ """Migrate Docker volumes from one name prefix to another.
7
+
8
+ Matches every volume named {from_prefix}{tail}
9
+ and copies it to {to_prefix}{tail}
10
+
11
+ The tail is whatever follows the FROM prefix - any suffix (_home,
12
+ _workspace, _certs, _db, ...). The destination is overwritten via
13
+ rsync --delete; the source volume is left intact for verification
14
+ before manual removal.
15
+
16
+ Usage:
17
+ ./migrate_volumes.py --from stellars-tech-ai-lab_ --to stellars-tech-ai-hub_
18
+ ./migrate_volumes.py --from stellars-tech-ai-lab_ --to stellars-tech-ai-hub_ --filter '_certs$'
19
+ ./migrate_volumes.py --from stellars-tech-ai-lab_ --to stellars-tech-ai-hub_ --dry-run
20
+ ./migrate_volumes.py --from stellars-tech-ai-lab_ --to stellars-tech-ai-hub_ --yes
21
+
22
+ Note: Docker encodes '.' in volume names as '-2e' (e.g. 'alice.smith'
23
+ appears as 'alice-2esmith'). The --filter regex matches against the
24
+ full source volume name.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import os
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from concurrent.futures import ThreadPoolExecutor
36
+ from dataclasses import dataclass, field
37
+
38
+ # Force truecolor so Rich/Textual emit exact 24-bit hex instead of downsampling
39
+ # to the 256-color palette (where dark slates land on xterm teal). WSL / Docker
40
+ # Desktop terminals frequently leave COLORTERM unset even though the host
41
+ # terminal (Windows Terminal, VS Code) renders truecolor fine. setdefault so an
42
+ # explicit user override still wins.
43
+ os.environ.setdefault("COLORTERM", "truecolor")
44
+
45
+
46
+ def _preflight() -> None:
47
+ """Verify the third-party deps are importable before anything else loads.
48
+ Runs ahead of the rich/textual imports so a missing package yields a clear
49
+ 'install this' message instead of a raw ImportError. When launched via the
50
+ shebang (`uv run --script`) uv installs the inline deps first, so this is a
51
+ silent no-op; it only bites when the script is run with a plain Python that
52
+ lacks the packages."""
53
+ import importlib.util
54
+
55
+ def _present(mod: str) -> bool:
56
+ # find_spec can raise (e.g. broken parent package); treat any failure
57
+ # as "not importable" so the preflight itself never tips over.
58
+ try:
59
+ return importlib.util.find_spec(mod) is not None
60
+ except Exception:
61
+ return False
62
+
63
+ required = {"rich": "rich>=13", "textual": "textual>=0.80"}
64
+ missing = [spec for mod, spec in required.items() if not _present(mod)]
65
+ if not missing:
66
+ return
67
+ print("migrate_volumes.py is missing required packages:", file=sys.stderr)
68
+ for spec in missing:
69
+ print(f" - {spec}", file=sys.stderr)
70
+ print(f"\nInstall with: pip install {' '.join(missing)}", file=sys.stderr)
71
+ print("Or run via uv: ./scripts/migrate_volumes.py (auto-installs)",
72
+ file=sys.stderr)
73
+ sys.exit(1)
74
+
75
+
76
+ _preflight()
77
+
78
+ from rich.console import Console, Group
79
+ from rich.prompt import Prompt
80
+ from rich.table import Table
81
+ from rich.text import Text
82
+ from rich.theme import Theme
83
+
84
+
85
+ # Duoptimum brand palette: cyan + orange against a dark blue-grey base.
86
+ # Sourced from stellars-jupyterhub-ds/services/jupyterhub/duoptimum-hub-web/
87
+ # src/theme/tokens.ts (DARK). All non-brand roles derived as hue shifts.
88
+ DUO = {
89
+ # Brand cyan family
90
+ "cyan": "#21a8e4", # accent
91
+ "cyan_bright": "#46bcf0", # accentHover
92
+ "cyan_deep": "#0e93cf", # accentActive
93
+ "cyan_dark": "#0096d1", # info
94
+ # Brand orange family
95
+ "orange": "#da8230", # accent2 / warning
96
+ "orange_bright": "#f0a050", # hue shift, lighter
97
+ "orange_dark": "#a86420", # hue shift, darker
98
+ # Derived hue shifts (single-step rotations off cyan / orange)
99
+ "amber": "#e6c660", # orange -> yellow (filter highlight)
100
+ "mint": "#3fb950", # cyan -> green (success)
101
+ "rose": "#ef4444", # warm danger
102
+ # Dark base (blacks)
103
+ "bg_dim": "#1a1f25", # screen background - dimmer, lets items pop
104
+ "bg": "#252b32",
105
+ "bg_subtle": "#2a313a",
106
+ "surface": "#303841",
107
+ "surface_hi": "#374049",
108
+ "border": "#404b54",
109
+ "border_hi": "#4d5a65",
110
+ # Text
111
+ "text": "#c3c3c3",
112
+ "text_muted": "#a5a5a5",
113
+ "text_subtle": "#7d8791",
114
+ }
115
+
116
+ # Role mapping (kept as PASTEL key for site compatibility; values now duoptimum)
117
+ PASTEL = {
118
+ "from": DUO["orange"], # source highlight (left)
119
+ "to": DUO["cyan"], # destination highlight (right)
120
+ "filter": DUO["amber"], # filter-match user portion (both)
121
+ "user": DUO["text"], # neutral user-segment text
122
+ "dim": DUO["text_subtle"], # filter-excluded rows
123
+ "title": DUO["cyan_bright"], # panel headers / section titles
124
+ "label": DUO["text_muted"], # field descriptions
125
+ "ok": DUO["mint"], # green for success
126
+ "warn": DUO["orange"], # orange for warnings
127
+ "err": DUO["rose"], # red for errors
128
+ "info": DUO["cyan_dark"], # info text
129
+ "accent": DUO["orange"], # OVERALL progress bar
130
+ "bar_bg": DUO["surface"], # bar background
131
+ }
132
+
133
+
134
+ ALPINE_IMG = "alpine:latest"
135
+
136
+ # Remap rich's named primaries to the pastel palette so existing [red] / [bold blue]
137
+ # / etc. markup throughout the script renders softly without per-site rewrites.
138
+ # Also override rich's default progress.* / bar.* styles (speed=red, download=
139
+ # green, remaining=cyan, bar.complete=bright magenta) which read as too harsh.
140
+ PASTEL_THEME = Theme({
141
+ "red": PASTEL["err"],
142
+ "green": PASTEL["ok"],
143
+ "blue": PASTEL["info"],
144
+ "yellow": PASTEL["warn"],
145
+ "cyan": PASTEL["title"],
146
+ "magenta": PASTEL["accent"],
147
+ # progress bar internals
148
+ "bar.back": DUO["surface"],
149
+ "bar.complete": DUO["cyan"],
150
+ "bar.finished": DUO["mint"],
151
+ "bar.pulse": DUO["cyan"],
152
+ "progress.description": DUO["text"],
153
+ "progress.percentage": DUO["cyan"],
154
+ "progress.filesize": DUO["cyan_dark"],
155
+ "progress.filesize.total": DUO["text_muted"],
156
+ "progress.download": DUO["cyan_dark"],
157
+ "progress.data.speed": DUO["orange"],
158
+ "progress.remaining": DUO["text_muted"],
159
+ "progress.elapsed": DUO["text_muted"],
160
+ "progress.spinner": DUO["cyan"],
161
+ })
162
+
163
+ console = Console(theme=PASTEL_THEME)
164
+
165
+ VERSION = "1.2.2"
166
+ APP_TITLE = "Docker Volume Migrator"
167
+
168
+ # Shared top header bar: app name on the left, version pinned to the right
169
+ # corner. Embedded into each screen's CSS via {HEADER_CSS}.
170
+ HEADER_CSS = f"""
171
+ #app-header {{ height: 1; background: {DUO['bg_subtle']}; }}
172
+ #hdr-title {{ width: 1fr; padding: 0 2; color: {PASTEL['title']}; text-style: bold; }}
173
+ #hdr-version {{ width: auto; padding: 0 2; color: {DUO['text_subtle']}; }}
174
+ """
175
+
176
+
177
+ @dataclass
178
+ class Migration:
179
+ tail: str # the part of the name after the FROM prefix
180
+ src: str
181
+ dst: str
182
+ size_bytes: int = 0
183
+ error: str = ""
184
+ success: bool = field(default=False)
185
+ removed: bool = field(default=False)
186
+
187
+ @property
188
+ def label(self) -> str:
189
+ return self.tail
190
+
191
+
192
+ def human(b: int) -> str:
193
+ f = float(b)
194
+ for unit in ("B", "KB", "MB", "GB", "TB"):
195
+ if f < 1024:
196
+ return f"{f:.1f} {unit}"
197
+ f /= 1024
198
+ return f"{f:.1f} PB"
199
+
200
+
201
+ def fmt_duration(seconds: float) -> str:
202
+ """MM:SS, or H:MM:SS once it crosses an hour."""
203
+ s = int(seconds)
204
+ h, rem = divmod(s, 3600)
205
+ m, sec = divmod(rem, 60)
206
+ return f"{h}:{m:02d}:{sec:02d}" if h else f"{m:02d}:{sec:02d}"
207
+
208
+
209
+ def trunc_left(s: str, width: int) -> str:
210
+ """Truncate from the left with a leading ellipsis, keeping the tail (the
211
+ meaningful end of a volume name) visible."""
212
+ if len(s) <= width:
213
+ return s
214
+ return "…" + s[-(width - 1):]
215
+
216
+
217
+ def styled_volume(
218
+ full: str,
219
+ prefix: str,
220
+ prefix_style: str,
221
+ base_style: str,
222
+ match_style,
223
+ filt_re,
224
+ ) -> Text:
225
+ """Render a full volume name: the leading `prefix` in `prefix_style`, the
226
+ rest in `base_style`, with every filter-matched span overlaid in
227
+ `match_style`. The filter is matched against the whole name, so a match
228
+ anywhere (prefix, body, or tail) is highlighted."""
229
+ t = Text(full, style=base_style)
230
+ if prefix and full.startswith(prefix):
231
+ t.stylize(prefix_style, 0, len(prefix))
232
+ if filt_re:
233
+ for mo in filt_re.finditer(full):
234
+ if mo.end() > mo.start():
235
+ t.stylize(match_style, mo.start(), mo.end())
236
+ return t
237
+
238
+
239
+ def _common_prefix(names: list[str]) -> str:
240
+ """Longest string shared by the start of every name."""
241
+ if not names:
242
+ return ""
243
+ first, last = min(names), max(names)
244
+ i = 0
245
+ while i < len(first) and i < len(last) and first[i] == last[i]:
246
+ i += 1
247
+ return first[:i]
248
+
249
+
250
+ def autocomplete_prefix(value: str, names: list[str]) -> str:
251
+ """Fish-style one-segment autocomplete for a prefix field.
252
+
253
+ Extend `value` toward the common prefix of the `names` that start with it,
254
+ advancing by a single slug - up to and including the next `-`/`_` separator.
255
+ Returns `value` unchanged when the next segment is ambiguous (the common
256
+ prefix does not grow), so the user must type to disambiguate rather than
257
+ have a variant guessed for them."""
258
+ cands = [n for n in names if n.startswith(value)]
259
+ if not cands:
260
+ return value
261
+ lcp = _common_prefix(cands)
262
+ if len(lcp) <= len(value):
263
+ return value # nothing unambiguous to add - user must type
264
+ for i, ch in enumerate(lcp[len(value):]):
265
+ if ch in "-_":
266
+ return lcp[: len(value) + i + 1] # stop after the separator
267
+ return lcp # no separator ahead - take the whole unambiguous remainder
268
+
269
+
270
+ def docker(args: list[str], **kw) -> subprocess.CompletedProcess:
271
+ return subprocess.run(["docker", *args], text=True, **kw)
272
+
273
+
274
+ def list_volumes() -> set[str]:
275
+ r = docker(["volume", "ls", "-q"], capture_output=True, check=True)
276
+ return {v for v in r.stdout.split() if v}
277
+
278
+
279
+ def remove_volume(name: str) -> tuple[bool, str]:
280
+ r = docker(["volume", "rm", name], capture_output=True)
281
+ if r.returncode != 0:
282
+ return False, (r.stderr or r.stdout).strip()
283
+ return True, ""
284
+
285
+
286
+ def list_candidate_volumes() -> list[str]:
287
+ """All Docker volumes - the designer pool; FROM prefix narrows it."""
288
+ return sorted(list_volumes())
289
+
290
+
291
+ def volume_size(volume: str) -> int:
292
+ name = f"migrate-size-{volume}".replace("/", "-").replace(".", "-")[:63]
293
+ docker(["rm", "-f", name], capture_output=True)
294
+ r = docker(
295
+ ["run", "--rm", "--name", name, "-v", f"{volume}:/d:ro",
296
+ ALPINE_IMG, "du", "-sb", "/d"],
297
+ capture_output=True,
298
+ )
299
+ if r.returncode != 0:
300
+ return 0
301
+ try:
302
+ return int(r.stdout.split()[0])
303
+ except (ValueError, IndexError):
304
+ return 0
305
+
306
+
307
+ def discover_migrations(
308
+ from_prefix: str,
309
+ to_prefix: str,
310
+ user_filter: re.Pattern | None,
311
+ ) -> list[Migration]:
312
+ all_volumes = list_volumes()
313
+ pattern = re.compile(rf"^{re.escape(from_prefix)}(.+)$")
314
+ found = []
315
+ for v in all_volumes:
316
+ m = pattern.match(v)
317
+ if not m:
318
+ continue
319
+ tail = m.group(1)
320
+ if user_filter and not user_filter.search(v):
321
+ continue
322
+ dst = f"{to_prefix}{tail}"
323
+ if dst == v:
324
+ continue
325
+ found.append(Migration(tail=tail, src=v, dst=dst))
326
+ return sorted(found, key=lambda x: x.tail)
327
+
328
+
329
+ def ensure_dst_exists(m: Migration, all_volumes: set[str]) -> bool:
330
+ if m.dst in all_volumes:
331
+ return True
332
+ r = docker(["volume", "create", m.dst], capture_output=True)
333
+ if r.returncode == 0:
334
+ all_volumes.add(m.dst)
335
+ return True
336
+ m.error = f"failed to create dest volume: {r.stderr.strip()}"
337
+ return False
338
+
339
+
340
+ def print_plan(migs: list[Migration]) -> None:
341
+ t = Table(title="Migration Plan", show_lines=False)
342
+ t.add_column("#", justify="right", style="bold cyan")
343
+ t.add_column("Tail", style="bold")
344
+ t.add_column("Source", style="cyan")
345
+ t.add_column("Destination", style="green")
346
+ t.add_column("Size", justify="right", style="yellow")
347
+ total = 0
348
+ have_sizes = any(m.size_bytes for m in migs)
349
+ for i, m in enumerate(migs, 1):
350
+ size_str = human(m.size_bytes) if m.size_bytes else "-"
351
+ t.add_row(str(i), m.tail, m.src, m.dst, size_str)
352
+ total += m.size_bytes
353
+ console.print(t)
354
+ if have_sizes:
355
+ console.print(f"[bold]Total to copy: {human(total)}[/bold]")
356
+
357
+
358
+ def parse_selection(s: str, max_n: int) -> list[int]:
359
+ """Parse '1,3,5-7' style selection - returns sorted 1-based indices."""
360
+ result: set[int] = set()
361
+ for part in s.split(","):
362
+ part = part.strip()
363
+ if not part:
364
+ continue
365
+ if "-" in part:
366
+ a, b = part.split("-", 1)
367
+ start, end = int(a), int(b)
368
+ if start < 1 or end > max_n or start > end:
369
+ raise ValueError(f"range out of bounds: {part}")
370
+ result.update(range(start, end + 1))
371
+ else:
372
+ n = int(part)
373
+ if n < 1 or n > max_n:
374
+ raise ValueError(f"index out of bounds: {part}")
375
+ result.add(n)
376
+ return sorted(result)
377
+
378
+
379
+ def container_name(m: Migration) -> str:
380
+ """Disposable container name per migration - safe Docker name characters."""
381
+ safe = m.label.replace("/", "-").replace(".", "-")
382
+ return f"migrate-vol-{safe}"
383
+
384
+
385
+ def run_dry(m: Migration) -> bool:
386
+ """Mount both volumes and verify access without copying.
387
+ Each migration uses its own dedicated container, removed on exit."""
388
+ name = container_name(m)
389
+ # Defensive: remove any stale container with this name
390
+ docker(["rm", "-f", name], capture_output=True)
391
+ r = docker(
392
+ [
393
+ "run", "--rm", "--name", name,
394
+ "-v", f"{m.src}:/src:ro",
395
+ "-v", f"{m.dst}:/dst",
396
+ ALPINE_IMG, "sh", "-c",
397
+ "test -d /src && test -d /dst && ls -la /src > /dev/null && ls -la /dst > /dev/null",
398
+ ],
399
+ capture_output=True,
400
+ )
401
+ if r.returncode != 0:
402
+ m.error = (r.stderr or r.stdout).strip().splitlines()[-1] if (r.stderr or r.stdout) else "mount check failed"
403
+ return False
404
+ return True
405
+
406
+
407
+ PROGRESS_RE = re.compile(r"^\s*([\d,]+)\s+(\d+)%\s+[\d.]+[kKmMgGtT]?B/s")
408
+
409
+
410
+ def run_copy(m: Migration, on_progress, on_stage) -> bool:
411
+ """Run rsync inside an alpine container, parse --info=progress2 output.
412
+
413
+ Two stages: 'discovery' (apk add rsync + rsync's source scan, no byte
414
+ progress yet) then 'migration' (bytes flowing). `on_stage(name)` fires on
415
+ each transition; `on_progress(completed, total)` is called as bytes flow so
416
+ any front-end (Textual bar, etc.) can render it. The caller owns the
417
+ overall/aggregate counter.
418
+ """
419
+ name = container_name(m)
420
+ # Defensive: remove any stale container with this name
421
+ docker(["rm", "-f", name], capture_output=True)
422
+ cmd = [
423
+ "docker", "run", "--rm", "--name", name,
424
+ "-v", f"{m.src}:/src:ro",
425
+ "-v", f"{m.dst}:/dst",
426
+ ALPINE_IMG,
427
+ "sh", "-c",
428
+ # apk add rsync, then sync. -aAX preserves all metadata; --delete
429
+ # makes destination match source (clears default skeleton files).
430
+ "apk add --no-cache rsync >/dev/null 2>&1 && "
431
+ "rsync -aAX --delete --info=progress2 --no-inc-recursive /src/ /dst/",
432
+ ]
433
+ proc = subprocess.Popen(
434
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
435
+ text=True, bufsize=1,
436
+ )
437
+ last_err = ""
438
+ est_total = 0
439
+ last_bytes = 0
440
+ transferring = False
441
+ on_stage("discovery")
442
+ assert proc.stdout is not None
443
+ for line in proc.stdout:
444
+ line = line.rstrip()
445
+ match = PROGRESS_RE.match(line)
446
+ if match:
447
+ bytes_done = int(match.group(1).replace(",", ""))
448
+ pct = int(match.group(2))
449
+ if pct > 0:
450
+ # Real, measurable progress: rsync now reports a total. This is
451
+ # the true start of migration. Flip stage once, then feed the
452
+ # determinate bar. Re-estimate each line - later ones sharpen.
453
+ est_total = int(bytes_done * 100 / pct)
454
+ if not transferring:
455
+ on_stage("migration")
456
+ transferring = True
457
+ on_progress(bytes_done, est_total)
458
+ last_bytes = bytes_done
459
+ else:
460
+ # pct still 0 - transfer warming up, total unknown. Stay in
461
+ # discovery (orange pulse) instead of flashing a full green bar.
462
+ last_bytes = max(last_bytes, bytes_done)
463
+ elif line and "rsync" in line.lower() and ("error" in line.lower() or "failed" in line.lower()):
464
+ last_err = line
465
+ proc.wait()
466
+ if proc.returncode != 0:
467
+ m.error = last_err or f"rsync exit code {proc.returncode}"
468
+ return False
469
+ # Lock total to actual final bytes and mark complete
470
+ final = max(est_total, last_bytes)
471
+ m.size_bytes = final
472
+ on_progress(final, final)
473
+ return True
474
+
475
+
476
+ # ===========================================================================
477
+ # Interactive designer (Textual TUI)
478
+ # ===========================================================================
479
+
480
+
481
+ def run_designer(
482
+ candidates: list[str],
483
+ from_p: str = "",
484
+ to_p: str = "",
485
+ filt: str = "",
486
+ worker_count: int = 3,
487
+ overwrite: bool = False,
488
+ rm_src: bool = False,
489
+ ) -> tuple[str, str, str, int, bool, bool] | None:
490
+ """Launch designer; return (from, to, filter, workers, overwrite, rm_src) or None."""
491
+ # Import locally so non-interactive runs don't pay the textual import cost.
492
+ from textual.app import App, ComposeResult
493
+ from textual.binding import Binding
494
+ from textual.containers import Horizontal, Vertical, VerticalScroll
495
+ from textual.widgets import Checkbox, Footer, Input, Static
496
+
497
+ FROM_STYLE = f"black on {PASTEL['from']}"
498
+ TO_STYLE = f"black on {PASTEL['to']}"
499
+ FILT_STYLE = f"black on {PASTEL['filter']}"
500
+ USER_STYLE = PASTEL["user"]
501
+ DIM_STYLE = PASTEL["dim"]
502
+
503
+ class DesignerApp(App):
504
+ CSS = f"""
505
+ Screen {{ layout: vertical; background: {DUO['bg_dim']}; layers: base overlay; align: center middle; }}
506
+ {HEADER_CSS}
507
+ /* Validation warning: a content-sized overlay box centred by the
508
+ Screen, floating over the still-visible designer (same pattern as
509
+ the execution completion popup). */
510
+ #warn-box {{
511
+ layer: overlay;
512
+ display: none;
513
+ width: auto; height: auto;
514
+ max-width: 80%;
515
+ padding: 1 4;
516
+ background: {DUO['bg_subtle']};
517
+ border: heavy {DUO['rose']};
518
+ text-align: center;
519
+ }}
520
+ #warn-box.-show {{ display: block; }}
521
+ #inputs {{
522
+ height: auto;
523
+ padding: 1 2 0 2;
524
+ background: {DUO['bg_subtle']};
525
+ }}
526
+ #status-bar {{
527
+ height: auto;
528
+ padding: 0 2;
529
+ background: {DUO['bg_subtle']};
530
+ border-bottom: heavy {DUO['border']};
531
+ }}
532
+ .desc {{ color: {DUO['text_muted']}; height: 1; }}
533
+ Input {{
534
+ margin: 0 0 1 0;
535
+ background: {DUO['surface']};
536
+ border: round {DUO['border']};
537
+ color: {DUO['text']};
538
+ }}
539
+ Input:focus {{ border: round {DUO['cyan']}; }}
540
+
541
+ .row {{ height: auto; layout: horizontal; }}
542
+ .field-grow {{ width: 1fr; height: auto; padding-right: 1; }}
543
+ .field-small {{ width: 28; height: auto; padding-right: 1; }}
544
+ .field-tiny {{ width: 22; height: auto; }}
545
+
546
+ Checkbox {{
547
+ background: {DUO['bg_subtle']};
548
+ border: round {DUO['border']};
549
+ color: {DUO['text']};
550
+ margin: 0;
551
+ padding: 0 1;
552
+ }}
553
+ Checkbox:focus {{ border: round {DUO['cyan']}; }}
554
+ /* The button box (▐ ▌) uses 'surface' so it stays visible against the
555
+ Checkbox's 'bg_subtle'. Textual always renders the literal 'X' inner
556
+ glyph; off-state hides it by coloring it the same as the box bg. */
557
+ #overwrite > .toggle--button, #rmsrc > .toggle--button {{
558
+ background: {DUO['surface']};
559
+ color: {DUO['surface']};
560
+ }}
561
+ #overwrite.-on > .toggle--button, #rmsrc.-on > .toggle--button {{
562
+ background: {DUO['surface']};
563
+ color: {DUO['orange']};
564
+ text-style: bold;
565
+ }}
566
+
567
+ #panels {{ height: 1fr; background: {DUO['bg']}; }}
568
+ .col {{ width: 1fr; padding: 0 1; }}
569
+ .left-col {{ border-right: solid {DUO['border']}; }}
570
+ /* Panel bodies take Tab focus; ring stays invisible (panel bg) until
571
+ focused, then cyan - same affordance as the input controls. */
572
+ .panel-body {{ border: round {DUO['bg']}; }}
573
+ .panel-body:focus {{ border: round {DUO['cyan']}; }}
574
+ .col-title {{
575
+ background: {DUO['surface']};
576
+ color: {DUO['cyan_bright']};
577
+ padding: 0 1;
578
+ text-style: bold;
579
+ text-align: center;
580
+ height: 1;
581
+ }}
582
+
583
+ Footer {{ background: {DUO['bg_subtle']}; color: {DUO['text_muted']}; }}
584
+ """
585
+
586
+ BINDINGS = [
587
+ Binding("up", "focus_up", "Up", show=False),
588
+ Binding("down", "focus_down", "Down", show=False),
589
+ Binding("enter", "confirm", "Confirm", priority=True),
590
+ Binding("ctrl+s", "confirm", "Confirm", show=False),
591
+ Binding("shift+tab", "autocomplete", "Autocomplete", priority=True),
592
+ Binding("ctrl+c", "cancel", "Cancel"),
593
+ Binding("escape", "cancel", "Cancel"),
594
+ ]
595
+
596
+ # 2D row layout for arrow navigation
597
+ ROW_LAYOUT = [
598
+ ["from", "to"],
599
+ ["filter", "workers", "overwrite", "rmsrc"],
600
+ ]
601
+
602
+ def __init__(
603
+ self,
604
+ all_volumes: list[str],
605
+ from_p: str,
606
+ to_p: str,
607
+ filt: str,
608
+ worker_count: int,
609
+ overwrite: bool,
610
+ rm_src: bool,
611
+ ) -> None:
612
+ super().__init__()
613
+ self.all_volumes = all_volumes
614
+ self.from_p = from_p
615
+ self.to_p = to_p
616
+ self.filt = filt
617
+ self.worker_count = max(1, worker_count)
618
+ self.overwrite = overwrite
619
+ self.rm_src = rm_src
620
+ self.result: tuple[str, str, str, int, bool, bool] | None = None
621
+ self._prev_focus = None
622
+
623
+ def get_system_commands(self, screen):
624
+ # Fixed brand theme - drop the palette's "Theme" switcher.
625
+ for cmd in super().get_system_commands(screen):
626
+ if cmd.title != "Theme":
627
+ yield cmd
628
+
629
+ def compose(self) -> ComposeResult:
630
+ with Horizontal(id="app-header"):
631
+ yield Static(APP_TITLE, id="hdr-title")
632
+ yield Static(f"v{VERSION}", id="hdr-version")
633
+ with Vertical(id="inputs"):
634
+ with Horizontal(classes="row"):
635
+ with Vertical(classes="field-grow"):
636
+ yield Static(
637
+ f"[{PASTEL['label']}]FROM prefix - source prefix to match[/]",
638
+ classes="desc",
639
+ )
640
+ yield Input(
641
+ value=self.from_p,
642
+ placeholder="e.g. jupyterlab-",
643
+ id="from",
644
+ select_on_focus=False,
645
+ )
646
+ with Vertical(classes="field-grow"):
647
+ yield Static(
648
+ f"[{PASTEL['label']}]TO prefix - replacement destination prefix[/]",
649
+ classes="desc",
650
+ )
651
+ yield Input(
652
+ value=self.to_p,
653
+ placeholder="e.g. jupyterhub_jupyterlab_",
654
+ id="to",
655
+ select_on_focus=False,
656
+ )
657
+ with Horizontal(classes="row"):
658
+ with Vertical(classes="field-grow"):
659
+ yield Static(
660
+ f"[{PASTEL['label']}]FILTER regex - whole name (empty = all)[/]",
661
+ classes="desc",
662
+ )
663
+ yield Input(
664
+ value=self.filt,
665
+ placeholder="e.g. ^alice",
666
+ id="filter",
667
+ select_on_focus=False,
668
+ )
669
+ with Vertical(classes="field-small"):
670
+ yield Static(
671
+ f"[{PASTEL['label']}]WORKERS - parallel containers[/]",
672
+ classes="desc",
673
+ )
674
+ yield Input(
675
+ value=str(self.worker_count),
676
+ placeholder="3",
677
+ id="workers",
678
+ select_on_focus=False,
679
+ )
680
+ with Vertical(classes="field-tiny"):
681
+ yield Static(
682
+ f"[{PASTEL['label']}]OVERWRITE existing[/]",
683
+ classes="desc",
684
+ )
685
+ yield Checkbox(
686
+ "replace dst",
687
+ value=self.overwrite,
688
+ id="overwrite",
689
+ )
690
+ with Vertical(classes="field-tiny"):
691
+ yield Static(
692
+ f"[{PASTEL['label']}]REMOVE source[/]",
693
+ classes="desc",
694
+ )
695
+ yield Checkbox(
696
+ "rm src",
697
+ value=self.rm_src,
698
+ id="rmsrc",
699
+ )
700
+
701
+ yield Static(id="status-bar")
702
+
703
+ with Horizontal(id="panels"):
704
+ with Vertical(classes="col left-col"):
705
+ yield Static("BEFORE (source)", classes="col-title")
706
+ with VerticalScroll(classes="panel-body"):
707
+ yield Static(id="left-body")
708
+ with Vertical(classes="col"):
709
+ yield Static("AFTER (destination)", classes="col-title")
710
+ with VerticalScroll(classes="panel-body"):
711
+ yield Static(id="right-body")
712
+
713
+ yield Static(id="warn-box")
714
+ yield Footer()
715
+
716
+ def on_mount(self) -> None:
717
+ self.title = "Volume Migration Designer"
718
+ self.sub_title = (
719
+ "arrows navigate - Shift+Tab complete - "
720
+ "Enter confirm - Esc cancel"
721
+ )
722
+ self._refresh()
723
+ self.query_one("#from", Input).focus()
724
+
725
+ def on_input_changed(self, event) -> None:
726
+ if event.input.id == "from":
727
+ self.from_p = event.value
728
+ elif event.input.id == "to":
729
+ self.to_p = event.value
730
+ elif event.input.id == "filter":
731
+ self.filt = event.value
732
+ elif event.input.id == "workers":
733
+ try:
734
+ n = int(event.value)
735
+ self.worker_count = max(1, n)
736
+ except ValueError:
737
+ pass # keep previous valid value; status line flags it
738
+ self._refresh()
739
+
740
+ def on_checkbox_changed(self, event) -> None:
741
+ if event.checkbox.id == "overwrite":
742
+ self.overwrite = bool(event.value)
743
+ self._refresh()
744
+ elif event.checkbox.id == "rmsrc":
745
+ self.rm_src = bool(event.value)
746
+ self._refresh()
747
+
748
+ def on_input_submitted(self, event) -> None:
749
+ # Pressing Enter inside any input confirms the form.
750
+ self.action_confirm()
751
+
752
+ def _row_col(self, widget_id: str) -> tuple[int, int]:
753
+ for r, row in enumerate(self.ROW_LAYOUT):
754
+ if widget_id in row:
755
+ return r, row.index(widget_id)
756
+ return 0, 0
757
+
758
+ def _focus_at(self, r: int, c: int) -> None:
759
+ r %= len(self.ROW_LAYOUT)
760
+ row = self.ROW_LAYOUT[r]
761
+ c = min(max(0, c), len(row) - 1)
762
+ w = self.query_one(f"#{row[c]}")
763
+ w.focus()
764
+ # No select-all on entry; drop the cursor at the end of the value.
765
+ if isinstance(w, Input):
766
+ w.cursor_position = len(w.value)
767
+
768
+ def _focusable(self) -> bool:
769
+ return isinstance(self.focused, (Input, Checkbox))
770
+
771
+ def action_focus_up(self) -> None:
772
+ if isinstance(self.focused, VerticalScroll):
773
+ self.focused.scroll_up()
774
+ return
775
+ if not self._focusable():
776
+ self._focus_at(0, 0)
777
+ return
778
+ r, c = self._row_col(self.focused.id)
779
+ self._focus_at(r - 1, c)
780
+
781
+ def action_focus_down(self) -> None:
782
+ if isinstance(self.focused, VerticalScroll):
783
+ self.focused.scroll_down()
784
+ return
785
+ if not self._focusable():
786
+ self._focus_at(0, 0)
787
+ return
788
+ r, c = self._row_col(self.focused.id)
789
+ self._focus_at(r + 1, c)
790
+
791
+ def _focus_sibling(self, delta: int) -> None:
792
+ if not self._focusable():
793
+ return
794
+ r, c = self._row_col(self.focused.id)
795
+ row = self.ROW_LAYOUT[r]
796
+ if len(row) <= 1:
797
+ return
798
+ self._focus_at(r, (c + delta) % len(row))
799
+
800
+ async def on_key(self, event) -> None:
801
+ # While the validation warning is up, any key just dismisses it.
802
+ if self._warn_visible():
803
+ self._hide_warn()
804
+ event.prevent_default()
805
+ event.stop()
806
+ return
807
+ if event.key not in ("left", "right"):
808
+ return
809
+ f = self.focused
810
+ if isinstance(f, Checkbox):
811
+ # Checkbox has no cursor - left/right always navigates siblings.
812
+ self._focus_sibling(-1 if event.key == "left" else +1)
813
+ event.prevent_default()
814
+ event.stop()
815
+ return
816
+ if not isinstance(f, Input):
817
+ return
818
+ # Hand off left/right to row-sibling navigation when the cursor
819
+ # is already at the input edge - otherwise let Input move the cursor.
820
+ if event.key == "left" and f.cursor_position == 0:
821
+ self._focus_sibling(-1)
822
+ event.prevent_default()
823
+ event.stop()
824
+ elif event.key == "right" and f.cursor_position == len(f.value):
825
+ self._focus_sibling(+1)
826
+ event.prevent_default()
827
+ event.stop()
828
+
829
+ def _refresh(self) -> None:
830
+ from_re = None
831
+ from_ok = True
832
+ if self.from_p:
833
+ try:
834
+ from_re = re.compile(rf"^{re.escape(self.from_p)}(.+)$")
835
+ except re.error:
836
+ from_ok = False
837
+
838
+ filt_re = None
839
+ filt_ok = True
840
+ if self.filt:
841
+ try:
842
+ filt_re = re.compile(self.filt)
843
+ except re.error:
844
+ filt_ok = False
845
+
846
+ left_lines: list[Text] = []
847
+ right_lines: list[Text] = []
848
+ total_candidates = 0
849
+
850
+ for v in self.all_volumes:
851
+ if not from_re:
852
+ continue
853
+ m = from_re.match(v)
854
+ if not m:
855
+ continue
856
+ total_candidates += 1
857
+ tail = m.group(1)
858
+ # Filter matches against the WHOLE source name; non-matching rows
859
+ # are hidden so the panels show only what will migrate.
860
+ if filt_re and not filt_re.search(v):
861
+ continue
862
+
863
+ dst_name = f"{self.to_p}{tail}" if self.to_p \
864
+ else f"<TO?>{tail}"
865
+ left_lines.append(styled_volume(
866
+ v, self.from_p, FROM_STYLE, USER_STYLE, FILT_STYLE, filt_re,
867
+ ))
868
+ right_lines.append(styled_volume(
869
+ dst_name, self.to_p or "<TO?>", TO_STYLE, USER_STYLE,
870
+ FILT_STYLE, filt_re,
871
+ ))
872
+
873
+ # Status line at top of each panel
874
+ status_bits: list[str] = []
875
+ if not from_ok:
876
+ status_bits.append(f"[{PASTEL['err']}]invalid FROM regex[/]")
877
+ if not filt_ok:
878
+ status_bits.append(f"[{PASTEL['err']}]invalid FILTER regex[/]")
879
+ if from_re and total_candidates == 0:
880
+ status_bits.append(f"[{PASTEL['warn']}]no candidate volumes match FROM[/]")
881
+ elif from_re and not left_lines:
882
+ status_bits.append(f"[{PASTEL['warn']}]no volumes match FILTER[/]")
883
+ elif from_re and filt_re:
884
+ status_bits.append(
885
+ f"[{PASTEL['ok']}]{len(left_lines)}[/] of "
886
+ f"[{PASTEL['info']}]{total_candidates}[/] candidates match filter"
887
+ )
888
+ elif from_re:
889
+ status_bits.append(
890
+ f"[{PASTEL['info']}]{total_candidates}[/] candidates"
891
+ )
892
+ if not from_re:
893
+ status_bits.append(
894
+ f"[{PASTEL['label']}]type a FROM prefix to start[/]"
895
+ )
896
+ status_bits.append(
897
+ f"[{PASTEL['label']}]workers:[/] [{PASTEL['title']}]{self.worker_count}[/]"
898
+ )
899
+ ov_color = PASTEL["warn"] if self.overwrite else PASTEL["label"]
900
+ ov_label = "ON" if self.overwrite else "off"
901
+ status_bits.append(
902
+ f"[{PASTEL['label']}]overwrite:[/] [{ov_color}]{ov_label}[/]"
903
+ )
904
+ rm_color = PASTEL["err"] if self.rm_src else PASTEL["label"]
905
+ rm_label = "ON" if self.rm_src else "off"
906
+ status_bits.append(
907
+ f"[{PASTEL['label']}]rm-src:[/] [{rm_color}]{rm_label}[/]"
908
+ )
909
+
910
+ status = Text.from_markup(" ".join(status_bits) or " ")
911
+ self.query_one("#status-bar", Static).update(status)
912
+
913
+ no_rows = Text("(no rows)", style=f"italic {DIM_STYLE}")
914
+ left_panel = Group(*left_lines) if left_lines else no_rows
915
+ right_panel = Group(*right_lines) if right_lines else no_rows
916
+
917
+ self.query_one("#left-body", Static).update(left_panel)
918
+ self.query_one("#right-body", Static).update(right_panel)
919
+
920
+ def action_autocomplete(self) -> None:
921
+ # Shift+Tab: fish-style one-segment complete on FROM / TO.
922
+ if self._warn_visible():
923
+ self._hide_warn()
924
+ return
925
+ f = self.focused
926
+ if not isinstance(f, Input) or f.id not in ("from", "to"):
927
+ return
928
+ completed = autocomplete_prefix(f.value, self.all_volumes)
929
+ if completed != f.value:
930
+ f.value = completed # fires on_input_changed
931
+ f.cursor_position = len(completed)
932
+
933
+ def _warn_visible(self) -> bool:
934
+ return self.query_one("#warn-box", Static).has_class("-show")
935
+
936
+ def _show_warn(self, markup: str) -> None:
937
+ box = self.query_one("#warn-box", Static)
938
+ box.update(Text.from_markup(markup))
939
+ box.add_class("-show")
940
+ # Blur the input so the next keypress reaches App.on_key (a focused
941
+ # Input would otherwise swallow printable keys) - any key dismisses.
942
+ self._prev_focus = self.focused
943
+ self.set_focus(None)
944
+
945
+ def _hide_warn(self) -> None:
946
+ self.query_one("#warn-box", Static).remove_class("-show")
947
+ if self._prev_focus is not None:
948
+ self.set_focus(self._prev_focus)
949
+ self._prev_focus = None
950
+
951
+ def _validate(self) -> tuple[int, int]:
952
+ """Count (src==dst collisions, pre-existing destinations) for the
953
+ current FROM/TO/filter over the volume pool."""
954
+ if not self.from_p or not self.to_p:
955
+ return 0, 0
956
+ try:
957
+ from_re = re.compile(rf"^{re.escape(self.from_p)}(.+)$")
958
+ except re.error:
959
+ return 0, 0
960
+ filt_re = None
961
+ if self.filt:
962
+ try:
963
+ filt_re = re.compile(self.filt)
964
+ except re.error:
965
+ filt_re = None
966
+ pool = set(self.all_volumes)
967
+ collisions = existing = 0
968
+ for v in self.all_volumes:
969
+ m = from_re.match(v)
970
+ if not m:
971
+ continue
972
+ if filt_re and not filt_re.search(v):
973
+ continue
974
+ dst = self.to_p + m.group(1)
975
+ if dst == v:
976
+ collisions += 1
977
+ elif dst in pool:
978
+ existing += 1
979
+ return collisions, existing
980
+
981
+ def action_confirm(self) -> None:
982
+ if self._warn_visible():
983
+ self._hide_warn()
984
+ return
985
+ collisions, existing = self._validate()
986
+ if collisions:
987
+ self._show_warn(
988
+ f"[bold {PASTEL['err']}]Cannot continue[/]\n\n"
989
+ f"[{PASTEL['warn']}]{collisions}[/] volume(s) would map to "
990
+ f"the same name - FROM and TO\nproduce identical names. "
991
+ f"This is not allowed;\nchange FROM or TO.\n\n"
992
+ f"[{PASTEL['label']}]press any key to dismiss[/]"
993
+ )
994
+ return
995
+ if existing and not self.overwrite:
996
+ self._show_warn(
997
+ f"[bold {PASTEL['err']}]Cannot continue[/]\n\n"
998
+ f"[{PASTEL['warn']}]{existing}[/] destination volume(s) "
999
+ f"already exist and\nOVERWRITE is off. Enable OVERWRITE to "
1000
+ f"clean\nand replace, or change TO.\n\n"
1001
+ f"[{PASTEL['label']}]press any key to dismiss[/]"
1002
+ )
1003
+ return
1004
+ self.result = (
1005
+ self.from_p, self.to_p, self.filt,
1006
+ self.worker_count, self.overwrite, self.rm_src,
1007
+ )
1008
+ self.exit()
1009
+
1010
+ def action_cancel(self) -> None:
1011
+ self.result = None
1012
+ self.exit()
1013
+
1014
+ app = DesignerApp(
1015
+ candidates, from_p, to_p, filt, worker_count, overwrite, rm_src,
1016
+ )
1017
+ app.run()
1018
+ return app.result
1019
+
1020
+
1021
+ # ===========================================================================
1022
+ # Interactive planner (Textual TUI)
1023
+ # ===========================================================================
1024
+
1025
+
1026
+ def run_planner(
1027
+ migs: list[Migration],
1028
+ worker_count: int,
1029
+ dry_run: bool,
1030
+ overwrite: bool,
1031
+ existing_dsts: set[str],
1032
+ from_prefix: str,
1033
+ to_prefix: str,
1034
+ filt_re: re.Pattern | None,
1035
+ rm_src: bool,
1036
+ ) -> tuple[str, list[int]] | None:
1037
+ """Show the migration plan; return ('confirm', indices) or ('edit', []) or
1038
+ ('cancel', []) or None when the window is closed."""
1039
+ from textual.app import App, ComposeResult
1040
+ from textual.binding import Binding
1041
+ from textual.containers import Horizontal
1042
+ from textual.widgets import Footer, OptionList, Static
1043
+ from textual.widgets.option_list import Option
1044
+
1045
+ class PlannerApp(App):
1046
+ # SelectionList clamps options to a single line (Textual 8.2.7), which
1047
+ # hid wrapped targets; OptionList renders multi-line options, so we use
1048
+ # it with a self-managed selection set and a custom orange-X marker.
1049
+ CSS = f"""
1050
+ Screen {{ layout: vertical; background: {DUO['bg_dim']}; }}
1051
+ {HEADER_CSS}
1052
+ #header {{
1053
+ height: auto;
1054
+ padding: 1 2;
1055
+ background: {DUO['bg_subtle']};
1056
+ border-bottom: heavy {DUO['border']};
1057
+ color: {DUO['text']};
1058
+ }}
1059
+
1060
+ OptionList {{
1061
+ background: {DUO['bg_dim']};
1062
+ color: {DUO['text']};
1063
+ padding: 0 2;
1064
+ border: none;
1065
+ height: 1fr;
1066
+ }}
1067
+ OptionList > .option-list--option {{ text-wrap: nowrap; }}
1068
+ /* Subtle current-row highlight - override Textual's bright $block-cursor
1069
+ background (blurred and focused) so the target text stays legible. */
1070
+ OptionList > .option-list--option-highlighted {{
1071
+ background: {DUO['surface']};
1072
+ color: {DUO['text']};
1073
+ text-style: none;
1074
+ }}
1075
+ OptionList:focus > .option-list--option-highlighted {{
1076
+ background: {DUO['surface']};
1077
+ color: {DUO['text']};
1078
+ text-style: none;
1079
+ }}
1080
+
1081
+ Footer {{ background: {DUO['bg_subtle']}; color: {DUO['text_muted']}; }}
1082
+ """
1083
+
1084
+ BINDINGS = [
1085
+ Binding("space", "toggle", "Toggle", priority=True),
1086
+ Binding("enter", "confirm", "Run selected", priority=True),
1087
+ Binding("ctrl+s", "confirm", "Run selected", show=False),
1088
+ Binding("e", "edit", "Edit (back)"),
1089
+ Binding("a", "select_all", "All"),
1090
+ Binding("n", "select_none", "None"),
1091
+ Binding("ctrl+c", "cancel", "Cancel"),
1092
+ Binding("escape", "cancel", "Cancel"),
1093
+ ]
1094
+
1095
+ def __init__(
1096
+ self,
1097
+ migs: list[Migration],
1098
+ worker_count: int,
1099
+ dry_run: bool,
1100
+ overwrite: bool,
1101
+ existing_dsts: set[str],
1102
+ from_prefix: str,
1103
+ to_prefix: str,
1104
+ filt_re: re.Pattern | None,
1105
+ rm_src: bool,
1106
+ ) -> None:
1107
+ super().__init__()
1108
+ self.migs = migs
1109
+ self.worker_count = worker_count
1110
+ self.dry_run = dry_run
1111
+ self.overwrite = overwrite
1112
+ self.existing_dsts = existing_dsts
1113
+ self.from_prefix = from_prefix
1114
+ self.to_prefix = to_prefix
1115
+ self.filt_re = filt_re
1116
+ self.rm_src = rm_src
1117
+ self.conflicts = [
1118
+ i for i, m in enumerate(migs) if m.dst in existing_dsts
1119
+ ]
1120
+ self.selected: set[int] = set(range(len(migs)))
1121
+ self.result: tuple[str, list[int]] | None = None
1122
+
1123
+ def get_system_commands(self, screen):
1124
+ # Fixed brand theme - drop the palette's "Theme" switcher.
1125
+ for cmd in super().get_system_commands(screen):
1126
+ if cmd.title != "Theme":
1127
+ yield cmd
1128
+
1129
+ def _styled_name(self, full: str, prefix: str, prefix_style: str) -> Text:
1130
+ """Render full volume name with prefix colored and every
1131
+ filter-matched span (anywhere in the name) highlighted."""
1132
+ return styled_volume(
1133
+ full, prefix, f"bold {prefix_style}", PASTEL["user"],
1134
+ f"bold {PASTEL['filter']}", self.filt_re,
1135
+ )
1136
+
1137
+ def _prompt(self, i: int) -> Text:
1138
+ """Build the (possibly two-line) row content for migration i."""
1139
+ m = self.migs[i]
1140
+ selected = i in self.selected
1141
+ # 2-cell marker gutter: orange 'X ' when selected, blank otherwise
1142
+ line = Text()
1143
+ line.append("X " if selected else " ",
1144
+ style=f"bold {DUO['orange']}")
1145
+ if m.dst in self.existing_dsts:
1146
+ line.append("[exists] ", style=f"bold {DUO['amber']}")
1147
+ src = self._styled_name(m.src, self.from_prefix, PASTEL["from"])
1148
+ dst = self._styled_name(m.dst, self.to_prefix, PASTEL["to"])
1149
+ line.append_text(src)
1150
+ sep = " -> "
1151
+ width = self.size.width or 100
1152
+ # padding (2+2) + marker (2) already in line; small slack
1153
+ avail = max(40, width - 6)
1154
+ inline_len = 2 + line.cell_len - 2 + len(sep) + dst.cell_len
1155
+ if inline_len <= avail:
1156
+ line.append(sep, style=PASTEL["label"])
1157
+ line.append_text(dst)
1158
+ else:
1159
+ # target on its own line, indented under the source name
1160
+ line.append("\n -> ", style=PASTEL["label"])
1161
+ line.append_text(dst)
1162
+ return line
1163
+
1164
+ def _rebuild(self) -> None:
1165
+ ol = self.query_one(OptionList)
1166
+ hi = ol.highlighted
1167
+ ol.clear_options()
1168
+ ol.add_options(
1169
+ [Option(self._prompt(i), id=str(i)) for i in range(len(self.migs))]
1170
+ )
1171
+ if self.migs:
1172
+ ol.highlighted = 0 if hi is None else min(hi, len(self.migs) - 1)
1173
+
1174
+ def compose(self) -> ComposeResult:
1175
+ mode_label = "DRY-RUN" if self.dry_run else "MIGRATION"
1176
+ ov_label = "ON (clean+replace)" if self.overwrite else "off (error if exists)"
1177
+ ov_color = PASTEL["warn"] if self.overwrite else PASTEL["label"]
1178
+ rm_label = "ON (delete src)" if self.rm_src else "off (keep src)"
1179
+ rm_color = PASTEL["err"] if self.rm_src else PASTEL["label"]
1180
+ header_lines = [
1181
+ f"[{PASTEL['title']}]MIGRATION PLAN[/] "
1182
+ f"[{PASTEL['label']}]mode:[/] [{PASTEL['warn']}]{mode_label}[/] "
1183
+ f"[{PASTEL['label']}]workers:[/] [{PASTEL['title']}]{self.worker_count}[/] "
1184
+ f"[{PASTEL['label']}]volumes:[/] [{PASTEL['ok']}]{len(self.migs)}[/] "
1185
+ f"[{PASTEL['label']}]overwrite:[/] [{ov_color}]{ov_label}[/] "
1186
+ f"[{PASTEL['label']}]rm-src:[/] [{rm_color}]{rm_label}[/]"
1187
+ ]
1188
+ if self.rm_src:
1189
+ header_lines.append(
1190
+ f"[{PASTEL['err']}]WARNING:[/] source volumes will be "
1191
+ f"[bold {PASTEL['err']}]DELETED[/] after each successful copy."
1192
+ )
1193
+ if self.conflicts:
1194
+ if self.overwrite:
1195
+ header_lines.append(
1196
+ f"[{PASTEL['err']}]WARNING:[/] "
1197
+ f"[{PASTEL['warn']}]{len(self.conflicts)}[/] "
1198
+ f"destination volume(s) already exist - contents will be "
1199
+ f"[bold {PASTEL['warn']}]CLEANED and REPLACED[/] in place "
1200
+ f"(volume kept)."
1201
+ )
1202
+ else:
1203
+ header_lines.append(
1204
+ f"[{PASTEL['err']}]ERROR:[/] "
1205
+ f"[{PASTEL['err']}]{len(self.conflicts)}[/] "
1206
+ f"destination volume(s) already exist - migration will "
1207
+ f"[bold {PASTEL['err']}]ABORT[/] "
1208
+ f"(toggle OVERWRITE in designer to clean and replace)."
1209
+ )
1210
+ header_lines.append(
1211
+ "[dim]Space toggles row a = all n = none "
1212
+ "e = edit (back to designer) "
1213
+ "Enter = run selected Esc = cancel[/]"
1214
+ )
1215
+ with Horizontal(id="app-header"):
1216
+ yield Static(APP_TITLE, id="hdr-title")
1217
+ yield Static(f"v{VERSION}", id="hdr-version")
1218
+ yield Static(
1219
+ Text.from_markup("\n".join(header_lines)),
1220
+ id="header",
1221
+ )
1222
+ yield OptionList(id="plan")
1223
+ yield Footer()
1224
+
1225
+ def on_mount(self) -> None:
1226
+ self.title = "Volume Migration Plan"
1227
+ self.sub_title = (
1228
+ "review selection - Enter to run, 'e' to edit, Esc to cancel"
1229
+ )
1230
+ self._rebuild()
1231
+ self.query_one(OptionList).focus()
1232
+
1233
+ def on_resize(self, event) -> None:
1234
+ # Width changed - inline-vs-wrapped decisions may flip; rebuild rows.
1235
+ self._rebuild()
1236
+
1237
+ def action_toggle(self) -> None:
1238
+ ol = self.query_one(OptionList)
1239
+ i = ol.highlighted
1240
+ if i is None:
1241
+ return
1242
+ if i in self.selected:
1243
+ self.selected.discard(i)
1244
+ else:
1245
+ self.selected.add(i)
1246
+ ol.replace_option_prompt_at_index(i, self._prompt(i))
1247
+
1248
+ def action_select_all(self) -> None:
1249
+ self.selected = set(range(len(self.migs)))
1250
+ self._rebuild()
1251
+
1252
+ def action_select_none(self) -> None:
1253
+ self.selected = set()
1254
+ self._rebuild()
1255
+
1256
+ def action_confirm(self) -> None:
1257
+ self.result = ("confirm", sorted(self.selected))
1258
+ self.exit()
1259
+
1260
+ def action_edit(self) -> None:
1261
+ self.result = ("edit", [])
1262
+ self.exit()
1263
+
1264
+ def action_cancel(self) -> None:
1265
+ self.result = ("cancel", [])
1266
+ self.exit()
1267
+
1268
+ app = PlannerApp(
1269
+ migs, worker_count, dry_run, overwrite, existing_dsts,
1270
+ from_prefix, to_prefix, filt_re, rm_src,
1271
+ )
1272
+ app.run()
1273
+ return app.result
1274
+
1275
+
1276
+ def run_execution(
1277
+ migs: list[Migration],
1278
+ worker_count: int,
1279
+ dry_run: bool,
1280
+ rm_src: bool,
1281
+ ) -> None:
1282
+ """Run the migrations on a managed (alt-screen) Textual screen.
1283
+
1284
+ A sticky OVERALL bar sits above a scrolling list of per-volume rows; as
1285
+ rows finish, the view auto-scrolls to keep the running/queued frontier in
1286
+ sight. Worker threads push progress in via call_from_thread. `migs` is
1287
+ mutated in place (success / removed / error); the caller prints the
1288
+ summary once the screen is dismissed."""
1289
+ from textual.app import App, ComposeResult
1290
+ from textual.binding import Binding
1291
+ from textual.containers import Horizontal, VerticalScroll
1292
+ from textual.widgets import Footer, ProgressBar, Static
1293
+
1294
+ LABEL_W = 46
1295
+ STAGE_W = 11
1296
+ title_word = "Verifying (dry-run)" if dry_run else "Migrating"
1297
+
1298
+ class MigRow(Horizontal):
1299
+ def __init__(self, idx: int, m: Migration) -> None:
1300
+ super().__init__(id=f"row-{idx}", classes="mig-row queued")
1301
+ self.m = m
1302
+
1303
+ def compose(self) -> ComposeResult:
1304
+ yield Static(trunc_left(self.m.src, LABEL_W), classes="vol-name")
1305
+ yield Static(Text("queued", style=DUO["text_subtle"]), classes="stage")
1306
+ yield ProgressBar(
1307
+ total=1, show_eta=False, show_percentage=not dry_run, id="bar",
1308
+ )
1309
+
1310
+ def state(self, cls: str) -> None:
1311
+ self.remove_class("queued", "running", "done", "fail")
1312
+ self.add_class(cls)
1313
+
1314
+ def set_stage(self, text: str, color: str) -> None:
1315
+ self.query_one(".stage", Static).update(Text(text, style=color))
1316
+
1317
+ class MigrationApp(App):
1318
+ CSS = f"""
1319
+ Screen {{ background: {DUO['bg_dim']}; layers: base overlay; align: center middle; }}
1320
+ {HEADER_CSS}
1321
+ #overall-row {{
1322
+ height: auto; padding: 0 2;
1323
+ background: {DUO['bg_subtle']};
1324
+ border-bottom: heavy {DUO['border']};
1325
+ }}
1326
+ #overall-name {{ width: {LABEL_W + 2 + STAGE_W}; color: {DUO['cyan']}; text-style: bold; }}
1327
+
1328
+ #rows {{ height: 1fr; padding: 0 2; background: {DUO['bg']}; }}
1329
+ .mig-row {{ height: 1; }}
1330
+ .vol-name {{ width: {LABEL_W + 2}; }}
1331
+ .stage {{ width: {STAGE_W}; }}
1332
+ .mig-row.queued .vol-name {{ color: {DUO['text_subtle']}; }}
1333
+ .mig-row.running .vol-name {{ color: {DUO['cyan']}; }}
1334
+ .mig-row.done .vol-name {{ color: {DUO['mint']}; }}
1335
+ .mig-row.fail .vol-name {{ color: {DUO['rose']}; }}
1336
+
1337
+ ProgressBar {{ width: 1fr; height: 1; }}
1338
+ Bar {{ width: 1fr; }}
1339
+ /* migration = determinate cyan; discovery = indeterminate orange pulse;
1340
+ done = mint; fail = rose. Overall bar is the standard cyan. */
1341
+ Bar > .bar--bar {{ color: {DUO['cyan']}; background: {DUO['surface']}; }}
1342
+ Bar > .bar--indeterminate {{ color: {DUO['orange']}; background: {DUO['surface']}; }}
1343
+ Bar > .bar--complete {{ color: {DUO['mint']}; background: {DUO['surface']}; }}
1344
+ .mig-row.fail Bar > .bar--bar {{ color: {DUO['rose']}; }}
1345
+ .mig-row.fail Bar > .bar--indeterminate {{ color: {DUO['rose']}; }}
1346
+ .mig-row.fail Bar > .bar--complete {{ color: {DUO['rose']}; }}
1347
+ #overall-row Bar > .bar--bar {{ color: {DUO['cyan']}; background: {DUO['surface']}; }}
1348
+ #overall-row Bar > .bar--complete {{ color: {DUO['mint']}; background: {DUO['surface']}; }}
1349
+
1350
+ /* Completion popup: a content-sized box on the overlay layer, centered
1351
+ by the Screen's align. Only its own footprint covers the rows - the
1352
+ execution screen stays visible behind it (a full-size overlay
1353
+ container would paint over everything, even when transparent). */
1354
+ #banner-box {{
1355
+ layer: overlay;
1356
+ display: none;
1357
+ width: auto; height: auto;
1358
+ padding: 1 4;
1359
+ background: {DUO['bg_subtle']};
1360
+ border: heavy {DUO['cyan']};
1361
+ text-align: center;
1362
+ }}
1363
+ #banner-box.-show {{ display: block; }}
1364
+
1365
+ Footer {{ background: {DUO['bg_subtle']}; color: {DUO['text_muted']}; }}
1366
+ """
1367
+
1368
+ BINDINGS = [Binding("ctrl+c", "quit", "Cancel")]
1369
+
1370
+ def __init__(self) -> None:
1371
+ super().__init__()
1372
+ self.migs = migs
1373
+ self.rows: dict[str, MigRow] = {}
1374
+ self.completed = 0
1375
+ self.finished = False
1376
+ self.start_time = 0.0
1377
+ self._pool: ThreadPoolExecutor | None = None
1378
+
1379
+ def get_system_commands(self, screen):
1380
+ # Fixed brand theme - drop the palette's "Theme" switcher.
1381
+ for cmd in super().get_system_commands(screen):
1382
+ if cmd.title != "Theme":
1383
+ yield cmd
1384
+
1385
+ def compose(self) -> ComposeResult:
1386
+ with Horizontal(id="app-header"):
1387
+ yield Static(
1388
+ f"{APP_TITLE} "
1389
+ f"[{PASTEL['label']}]{title_word} · {len(self.migs)} volumes · "
1390
+ f"{worker_count} workers[/]",
1391
+ id="hdr-title",
1392
+ )
1393
+ yield Static(f"v{VERSION}", id="hdr-version")
1394
+ with Horizontal(id="overall-row"):
1395
+ yield Static("OVERALL", id="overall-name")
1396
+ yield ProgressBar(
1397
+ total=len(self.migs), show_eta=False, show_percentage=True,
1398
+ id="overall-bar",
1399
+ )
1400
+ with VerticalScroll(id="rows"):
1401
+ for i, m in enumerate(self.migs):
1402
+ row = MigRow(i, m)
1403
+ self.rows[m.label] = row
1404
+ yield row
1405
+ yield Static(id="banner-box")
1406
+ yield Footer()
1407
+
1408
+ def on_mount(self) -> None:
1409
+ self.title = "Volume Migration"
1410
+ self.start_time = time.monotonic()
1411
+ self._pool = ThreadPoolExecutor(max_workers=worker_count)
1412
+ for m in self.migs:
1413
+ self._pool.submit(self._do_one, m)
1414
+
1415
+ # --- worker thread side -------------------------------------------
1416
+ def _do_one(self, m: Migration) -> None:
1417
+ self.call_from_thread(self._start_row, m)
1418
+ if dry_run:
1419
+ m.success = run_dry(m)
1420
+ else:
1421
+ m.success = run_copy(
1422
+ m,
1423
+ lambda done, total: self.call_from_thread(
1424
+ self._update_bar, m, done, total
1425
+ ),
1426
+ lambda stage: self.call_from_thread(self._set_stage, m, stage),
1427
+ )
1428
+ if m.success and rm_src:
1429
+ ok, err = remove_volume(m.src)
1430
+ m.removed = ok
1431
+ if not ok:
1432
+ m.error = f"copied ok, source removal failed: {err}"
1433
+ self.call_from_thread(self._complete_row, m)
1434
+
1435
+ # --- UI thread side -----------------------------------------------
1436
+ def _start_row(self, m: Migration) -> None:
1437
+ row = self.rows[m.label]
1438
+ row.state("running")
1439
+ if dry_run:
1440
+ row.set_stage("verify", DUO["cyan"])
1441
+ else:
1442
+ # Discovery: orange, indeterminate pulse until first byte.
1443
+ row.set_stage("discovery", DUO["orange"])
1444
+ row.query_one("#bar", ProgressBar).update(total=None)
1445
+ row.scroll_visible()
1446
+
1447
+ def _set_stage(self, m: Migration, stage: str) -> None:
1448
+ row = self.rows[m.label]
1449
+ if stage == "discovery":
1450
+ row.set_stage("discovery", DUO["orange"])
1451
+ row.query_one("#bar", ProgressBar).update(total=None)
1452
+ elif stage == "migration":
1453
+ row.set_stage("migration", DUO["cyan"])
1454
+
1455
+ def _update_bar(self, m: Migration, done: int, total: int) -> None:
1456
+ if total > 0:
1457
+ self.rows[m.label].query_one("#bar", ProgressBar).update(
1458
+ total=total, progress=done,
1459
+ )
1460
+
1461
+ def _complete_row(self, m: Migration) -> None:
1462
+ row = self.rows[m.label]
1463
+ bar = row.query_one("#bar", ProgressBar)
1464
+ if m.success:
1465
+ if bar.total is None:
1466
+ bar.update(total=1)
1467
+ bar.update(progress=bar.total or 1)
1468
+ row.state("done")
1469
+ row.set_stage("done", DUO["mint"])
1470
+ else:
1471
+ # Stop any discovery pulse so a failed row reads as stalled.
1472
+ if bar.total is None:
1473
+ bar.update(total=1, progress=0)
1474
+ row.state("fail")
1475
+ row.set_stage("failed", DUO["rose"])
1476
+ self.query_one("#overall-bar", ProgressBar).advance(1)
1477
+ self.completed += 1
1478
+ if self.completed >= len(self.migs):
1479
+ self._finish()
1480
+ else:
1481
+ self._scroll_frontier()
1482
+
1483
+ def _scroll_frontier(self) -> None:
1484
+ for m in self.migs:
1485
+ row = self.rows[m.label]
1486
+ if row.has_class("running") or row.has_class("queued"):
1487
+ row.scroll_visible()
1488
+ return
1489
+
1490
+ def _finish(self) -> None:
1491
+ self.finished = True
1492
+ elapsed = time.monotonic() - self.start_time
1493
+ ok = sum(1 for x in self.migs if x.success)
1494
+ fails = len(self.migs) - ok
1495
+ verb = "Verification" if dry_run else "Migration"
1496
+ state, color = ("complete", DUO["mint"]) if fails == 0 else ("failed", DUO["rose"])
1497
+ fail_color = DUO["rose"] if fails else DUO["text_muted"]
1498
+ lines = [
1499
+ f"[bold {color}]{verb} {state}[/]",
1500
+ "",
1501
+ f"[{DUO['mint']}]{ok}[/] ok [{fail_color}]{fails}[/] failed",
1502
+ f"[{DUO['text_muted']}]elapsed[/] "
1503
+ f"[{DUO['cyan']}]{fmt_duration(elapsed)}[/]",
1504
+ "",
1505
+ f"[{DUO['text_subtle']}]press any key to close[/]",
1506
+ ]
1507
+ box = self.query_one("#banner-box", Static)
1508
+ box.update(Text.from_markup("\n".join(lines)))
1509
+ box.styles.border = ("heavy", color)
1510
+ box.add_class("-show")
1511
+ self.sub_title = f"done - {ok}/{len(self.migs)} ok · press any key"
1512
+
1513
+ def on_key(self, event) -> None:
1514
+ if self.finished:
1515
+ self.exit()
1516
+
1517
+ app = MigrationApp()
1518
+ app.run()
1519
+ if app._pool is not None:
1520
+ app._pool.shutdown(wait=False)
1521
+
1522
+
1523
+ def main() -> int:
1524
+ p = argparse.ArgumentParser(
1525
+ description=__doc__,
1526
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1527
+ )
1528
+ p.add_argument("--from", dest="from_prefix", metavar="PREFIX",
1529
+ help="Source volume name prefix (e.g. 'jupyterlab-')")
1530
+ p.add_argument("--to", dest="to_prefix", metavar="PREFIX",
1531
+ help="Destination volume name prefix (e.g. 'jupyterhub_jupyterlab_')")
1532
+ p.add_argument("--filter", dest="user_filter", metavar="REGEX",
1533
+ help="Regex applied to the full source volume name")
1534
+ p.add_argument("--dry-run", action="store_true", help="Mount only, no copy")
1535
+ p.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
1536
+ p.add_argument("--workers", type=int, default=3, metavar="N",
1537
+ help="Parallel rsync containers (default 3)")
1538
+ p.add_argument("--overwrite", action="store_true",
1539
+ help="Overwrite destination volumes that already exist "
1540
+ "(default: skip them)")
1541
+ p.add_argument("--remove-source", dest="rm_src", action="store_true",
1542
+ help="Delete each source volume after its successful copy "
1543
+ "(default: keep sources)")
1544
+
1545
+ args = p.parse_args()
1546
+
1547
+ no_args = len(sys.argv) == 1
1548
+ if not no_args:
1549
+ if args.workers < 1:
1550
+ p.error("--workers must be >= 1")
1551
+ if not (args.yes and args.from_prefix and args.to_prefix):
1552
+ # Even with partial CLI args, fall through to the interactive loop
1553
+ # to let the user complete / review the plan via the TUI.
1554
+ pass
1555
+ elif not args.from_prefix or not args.to_prefix:
1556
+ p.error("--from and --to are required (or use no args for the designer)")
1557
+
1558
+ candidates = list_candidate_volumes()
1559
+ if not candidates and no_args:
1560
+ console.print(
1561
+ f"[{PASTEL['warn']}]No Docker volumes found.[/]"
1562
+ )
1563
+ return 0
1564
+
1565
+ # Designer + planner loop. The 'edit' action on the planner returns here.
1566
+ fp = args.from_prefix or ""
1567
+ tp = args.to_prefix or ""
1568
+ flt = args.user_filter or ""
1569
+ workers = args.workers
1570
+ overwrite = args.overwrite
1571
+ rm_src = args.rm_src
1572
+ next_screen = "designer" if no_args or not (fp and tp) else "planner"
1573
+ selected_migs: list[Migration] | None = None
1574
+
1575
+ while True:
1576
+ if next_screen == "designer":
1577
+ designed = run_designer(
1578
+ candidates, fp, tp, flt, workers, overwrite, rm_src,
1579
+ )
1580
+ if designed is None:
1581
+ console.print(f"[{PASTEL['err']}]Cancelled.[/]")
1582
+ return 1
1583
+ fp, tp, flt, workers, overwrite, rm_src = designed
1584
+
1585
+ if not fp or not tp:
1586
+ console.print(
1587
+ f"[{PASTEL['err']}]--from and --to are both required.[/]"
1588
+ )
1589
+ next_screen = "designer"
1590
+ continue
1591
+
1592
+ try:
1593
+ user_filter = re.compile(flt) if flt else None
1594
+ except re.error as e:
1595
+ console.print(f"[{PASTEL['err']}]invalid filter regex: {e}[/]")
1596
+ next_screen = "designer"
1597
+ continue
1598
+
1599
+ migs = discover_migrations(fp, tp, user_filter)
1600
+ if not migs:
1601
+ console.print(
1602
+ f"[{PASTEL['warn']}]No matching volumes for the current "
1603
+ f"prefixes/filter - returning to designer.[/]"
1604
+ )
1605
+ next_screen = "designer"
1606
+ continue
1607
+
1608
+ existing_volumes = list_volumes()
1609
+
1610
+ if args.yes:
1611
+ selected_migs = migs
1612
+ break
1613
+
1614
+ plan_result = run_planner(
1615
+ migs, workers, args.dry_run, overwrite, existing_volumes,
1616
+ fp, tp, user_filter, rm_src,
1617
+ )
1618
+ if plan_result is None or plan_result[0] == "cancel":
1619
+ console.print(f"[{PASTEL['err']}]Cancelled.[/]")
1620
+ return 1
1621
+ if plan_result[0] == "edit":
1622
+ next_screen = "designer"
1623
+ continue
1624
+ if plan_result[0] == "confirm":
1625
+ indices = plan_result[1]
1626
+ if not indices:
1627
+ console.print(
1628
+ f"[{PASTEL['warn']}]No rows selected - back to designer.[/]"
1629
+ )
1630
+ next_screen = "designer"
1631
+ continue
1632
+ selected_migs = [migs[i] for i in indices]
1633
+ break
1634
+
1635
+ assert selected_migs is not None
1636
+ migs = selected_migs
1637
+ args.from_prefix = fp
1638
+ args.to_prefix = tp
1639
+ args.user_filter = flt or None
1640
+ args.workers = workers
1641
+ args.overwrite = overwrite
1642
+ args.rm_src = rm_src
1643
+
1644
+ # Check destination state vs. overwrite policy. An existing destination is
1645
+ # only ever overwritten in place - rsync --delete mirrors the source into
1646
+ # the kept volume (cleaned, never removed/recreated). With overwrite off,
1647
+ # any pre-existing destination is a hard error: abort before copying.
1648
+ all_volumes = list_volumes()
1649
+ conflicts = [m for m in migs if m.dst in all_volumes]
1650
+ if conflicts and not args.overwrite:
1651
+ console.print(
1652
+ f"[{PASTEL['err']}]Error: {len(conflicts)} destination volume(s) "
1653
+ f"already exist and overwrite is off:[/]"
1654
+ )
1655
+ for m in conflicts:
1656
+ console.print(f" [{PASTEL['err']}]·[/] {m.dst}")
1657
+ console.print(
1658
+ f"[{PASTEL['err']}]Aborting - nothing copied. Enable overwrite to "
1659
+ f"clean and replace them (volume kept, contents mirrored from "
1660
+ f"source).[/]"
1661
+ )
1662
+ return 2
1663
+
1664
+ for m in migs:
1665
+ ensure_dst_exists(m, all_volumes)
1666
+
1667
+ if not migs:
1668
+ console.print("[yellow]Nothing to run.[/yellow]")
1669
+ else:
1670
+ run_execution(migs, args.workers, args.dry_run, args.rm_src)
1671
+
1672
+ # Summary
1673
+ console.rule("[bold]Summary[/bold]")
1674
+ ok_count = sum(1 for m in migs if m.success)
1675
+ fail_count = len(migs) - ok_count
1676
+ removed_count = sum(1 for m in migs if m.removed)
1677
+ summary = (
1678
+ f"Succeeded: [green]{ok_count}[/green] "
1679
+ f"Failed: [red]{fail_count}[/red]"
1680
+ )
1681
+ if args.rm_src and not args.dry_run:
1682
+ summary += f" Sources removed: [green]{removed_count}[/green]"
1683
+ console.print(summary)
1684
+ for m in migs:
1685
+ if not m.success:
1686
+ console.print(f" [red]FAIL[/red] {m.label}: {m.error}")
1687
+
1688
+ # Sources that copied OK but weren't removed - list manual cleanup commands.
1689
+ kept = [m for m in migs if m.success and not m.removed]
1690
+ if not args.dry_run and kept:
1691
+ console.print("\n[dim]Source volumes left intact. After verification, remove with:[/dim]")
1692
+ for m in kept:
1693
+ console.print(f" [dim]docker volume rm {m.src}[/dim]")
1694
+
1695
+ return 0 if fail_count == 0 else 2
1696
+
1697
+
1698
+ if __name__ == "__main__":
1699
+ sys.exit(main())