|
|
|
@ -1,6 +1,6 @@
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
|
|
|
|
VERSION = "0.3.0"
|
|
|
|
VERSION = "0.4.0"
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
import re
|
|
|
|
import sys
|
|
|
|
import sys
|
|
|
|
@ -25,6 +25,10 @@ class Colors:
|
|
|
|
BOLD = '\033[1m'
|
|
|
|
BOLD = '\033[1m'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Simple inline progress bar renderer
|
|
|
|
|
|
|
|
_BAR_WIDTH = 28
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def colored(text: str, color: str) -> str:
|
|
|
|
def colored(text: str, color: str) -> str:
|
|
|
|
"""Wrap text with an ANSI color.
|
|
|
|
"""Wrap text with an ANSI color.
|
|
|
|
|
|
|
|
|
|
|
|
@ -359,6 +363,66 @@ def parse_rsync_action(line: str) -> str:
|
|
|
|
return f"{direction} {item_type.upper()}: {filename}" if not details \
|
|
|
|
return f"{direction} {item_type.upper()}: {filename}" if not details \
|
|
|
|
else f"{direction} {item_type.upper()}: {filename} ({', '.join(details)})"
|
|
|
|
else f"{direction} {item_type.upper()}: {filename} ({', '.join(details)})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_progress(prefix: str, pct: int) -> None:
|
|
|
|
|
|
|
|
"""Render a single-line progress bar like: '[#####.....] 42%'.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
|
|
prefix: Text shown before the bar (e.g., node label).
|
|
|
|
|
|
|
|
pct: Integer percent [0..100].
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
pct = max(0, min(100, pct))
|
|
|
|
|
|
|
|
filled = int(_BAR_WIDTH * pct / 100)
|
|
|
|
|
|
|
|
bar = "#" * filled + "." * (_BAR_WIDTH - filled)
|
|
|
|
|
|
|
|
end = "\r" if sys.stdout.isatty() else "\n"
|
|
|
|
|
|
|
|
print(f"{prefix} [{bar}] {pct:3d}% ", end=end, flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _run_rsync_with_progress(cmd: List[str], label: str) -> None:
|
|
|
|
|
|
|
|
"""Execute rsync and show a best-effort progress bar.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
|
|
|
- Removes '-v' for cleaner output and appends '--info=progress2'.
|
|
|
|
|
|
|
|
- Parses percentage from rsync output and updates a single-line bar.
|
|
|
|
|
|
|
|
- Forces a final 100% on success when rsync emits no/low progress.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
base = [c for c in cmd if c != "-v"]
|
|
|
|
|
|
|
|
if "--info=progress2" not in base:
|
|
|
|
|
|
|
|
base.append("--info=progress2")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proc = subprocess.Popen(
|
|
|
|
|
|
|
|
base,
|
|
|
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
|
|
bufsize=1,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
last_pct = -1
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
assert proc.stdout is not None
|
|
|
|
|
|
|
|
for line in proc.stdout:
|
|
|
|
|
|
|
|
m = re.search(r"(\d+)%", line)
|
|
|
|
|
|
|
|
if m:
|
|
|
|
|
|
|
|
pct = int(m.group(1))
|
|
|
|
|
|
|
|
if pct != last_pct:
|
|
|
|
|
|
|
|
_render_progress(label, pct)
|
|
|
|
|
|
|
|
last_pct = pct
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rc = proc.wait()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# NEW: if rsync completed successfully but never reached 100% (or printed nothing),
|
|
|
|
|
|
|
|
# render a final 100% so the user doesn't see a stuck 0%.
|
|
|
|
|
|
|
|
if rc == 0 and last_pct != 100:
|
|
|
|
|
|
|
|
_render_progress(label, 100)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if sys.stdout.isatty():
|
|
|
|
|
|
|
|
print() # finalize the progress line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if rc != 0:
|
|
|
|
|
|
|
|
raise subprocess.CalledProcessError(rc, base)
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
|
|
if proc.stdout:
|
|
|
|
|
|
|
|
proc.stdout.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sync(reverse: bool = False) -> None:
|
|
|
|
def sync(reverse: bool = False) -> None:
|
|
|
|
"""Synchronize local project directory to all nodes.
|
|
|
|
"""Synchronize local project directory to all nodes.
|
|
|
|
@ -387,23 +451,22 @@ def sync(reverse: bool = False) -> None:
|
|
|
|
node = n["name"]
|
|
|
|
node = n["name"]
|
|
|
|
rdir = n["remote_dir"] or master_dir
|
|
|
|
rdir = n["remote_dir"] or master_dir
|
|
|
|
remote = f"{node}:{rdir}/" # trailing slash to copy dir contents
|
|
|
|
remote = f"{node}:{rdir}/" # trailing slash to copy dir contents
|
|
|
|
print(f"[Reverse] From {remote} -> {project_dir}/ (ignore-existing)")
|
|
|
|
label = f"[Reverse] {node} -> master"
|
|
|
|
# --ignore-existing ensures we only fetch files that do NOT exist locally.
|
|
|
|
|
|
|
|
pull_cmd = [
|
|
|
|
pull_cmd = [
|
|
|
|
"rsync", "-avz", "--ignore-existing",
|
|
|
|
"rsync", "-az", "--ignore-existing", # dropped '-v' for cleaner progress
|
|
|
|
f"--exclude-from={ignore_file}",
|
|
|
|
f"--exclude-from={ignore_file}",
|
|
|
|
"-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10",
|
|
|
|
"-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10",
|
|
|
|
remote, f"{project_dir}/"
|
|
|
|
remote, f"{project_dir}/"
|
|
|
|
]
|
|
|
|
]
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
subprocess.run(pull_cmd, check=True, capture_output=True, text=True)
|
|
|
|
_run_rsync_with_progress(pull_cmd, label)
|
|
|
|
print(colored(f"[OK] Pulled missing files from {node}", Colors.GREEN))
|
|
|
|
print(colored(f"[OK] Pulled missing files from {node}", Colors.GREEN))
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
print(colored(f"[X] Failed reverse-pull from {node} ({rdir}): {e}", Colors.RED))
|
|
|
|
print(colored(f"[X] Failed reverse-pull from {node} ({rdir}): {e}", Colors.RED))
|
|
|
|
|
|
|
|
|
|
|
|
print() # spacing
|
|
|
|
print() # spacing
|
|
|
|
|
|
|
|
|
|
|
|
# --- Forward phase: push master to all nodes (same as before) ---
|
|
|
|
# --- Forward phase: push master to all nodes ---
|
|
|
|
success_count = 0
|
|
|
|
success_count = 0
|
|
|
|
failed_nodes: List[str] = []
|
|
|
|
failed_nodes: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
@ -411,16 +474,16 @@ def sync(reverse: bool = False) -> None:
|
|
|
|
node = n["name"]
|
|
|
|
node = n["name"]
|
|
|
|
rdir = n["remote_dir"] or master_dir
|
|
|
|
rdir = n["remote_dir"] or master_dir
|
|
|
|
remote = f"{node}:{rdir}"
|
|
|
|
remote = f"{node}:{rdir}"
|
|
|
|
print(f"Syncing to {remote}...")
|
|
|
|
label = f"[Sync] {node}"
|
|
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
cmd = [
|
|
|
|
"rsync", "-avz", "--delete",
|
|
|
|
"rsync", "-az", "--delete", # dropped '-v' for cleaner progress
|
|
|
|
f"--exclude-from={ignore_file}",
|
|
|
|
f"--exclude-from={ignore_file}",
|
|
|
|
"-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10",
|
|
|
|
"-e", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10",
|
|
|
|
f"{project_dir}/", remote
|
|
|
|
f"{project_dir}/", remote
|
|
|
|
]
|
|
|
|
]
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
|
|
_run_rsync_with_progress(cmd, label)
|
|
|
|
print(colored(f"[OK] Successfully synced to {node} ({rdir})", Colors.GREEN))
|
|
|
|
print(colored(f"[OK] Successfully synced to {node} ({rdir})", Colors.GREEN))
|
|
|
|
success_count += 1
|
|
|
|
success_count += 1
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
|
|