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.
- docker_volume_toolkit-1.2.2.dist-info/METADATA +139 -0
- docker_volume_toolkit-1.2.2.dist-info/RECORD +6 -0
- docker_volume_toolkit-1.2.2.dist-info/WHEEL +4 -0
- docker_volume_toolkit-1.2.2.dist-info/entry_points.txt +2 -0
- docker_volume_toolkit-1.2.2.dist-info/licenses/LICENSE +21 -0
- migrate_volumes.py +1699 -0
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())
|