86ce602b28
rsync --delete 会清掉目标目录里的多余文件;先拷到 $HOME/bakeup/deployer-skill,失败则中止同步。 Co-authored-by: Cursor <cursoragent@cursor.com>
247 lines
7.3 KiB
Python
Executable File
247 lines
7.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
目录同步脚本
|
||
将指定目录同步到远程机器
|
||
|
||
用法:
|
||
python sync.py <目录路径>
|
||
例如: python sync.py hosts/web1/myapp
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import shlex
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
BAKEUP_KEEP = 5
|
||
BAKEUP_ROOT = "deployer-skill"
|
||
|
||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||
if str(_SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||
|
||
from lib import DEFAULT_BASE_PATH, rsync_ssh_args, scp_base_args, service_info, ssh_cmd # noqa: E402
|
||
|
||
|
||
def _safe_bakeup_name(name: str) -> str:
|
||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.")
|
||
return cleaned or "service"
|
||
|
||
|
||
def _backup_remote(info: dict) -> int:
|
||
"""把远程服务目录拷到 $HOME/bakeup/deployer-skill/<name>/<timestamp>/,失败则中止 sync。"""
|
||
name = _safe_bakeup_name(info["name"])
|
||
remote_dir = info["remote_dir"]
|
||
script = f"""
|
||
set -eu
|
||
remote_dir={shlex.quote(remote_dir)}
|
||
name={shlex.quote(name)}
|
||
root={shlex.quote(BAKEUP_ROOT)}
|
||
keep={BAKEUP_KEEP}
|
||
if [ ! -d "$remote_dir" ]; then
|
||
echo "跳过备份: 远程目录不存在 $remote_dir"
|
||
exit 0
|
||
fi
|
||
if [ -z "$(find "$remote_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||
echo "跳过备份: 远程目录为空 $remote_dir"
|
||
exit 0
|
||
fi
|
||
stamp=$(date +%Y%m%d-%H%M%S)
|
||
base="$HOME/bakeup/$root/$name"
|
||
dest="$base/$stamp"
|
||
mkdir -p "$dest"
|
||
chmod 700 "$HOME/bakeup" "$HOME/bakeup/$root" "$base" "$dest"
|
||
cp -a "$remote_dir"/. "$dest"/
|
||
ls -1d "$base"/[0-9][0-9][0-9][0-9]* 2>/dev/null | sort -r | tail -n +"$((keep + 1))" | while IFS= read -r old; do
|
||
rm -rf "$old"
|
||
done
|
||
echo "已备份到 $dest (保留最近 $keep 份)"
|
||
"""
|
||
cmd = ssh_cmd(info, script)
|
||
print(f"备份远程目录 {info['node']}:{remote_dir}")
|
||
print(f"备份位置: $HOME/bakeup/{BAKEUP_ROOT}/{name}/<时间戳>/")
|
||
print("-" * 60)
|
||
try:
|
||
completed = subprocess.run(cmd, check=False)
|
||
except FileNotFoundError:
|
||
print("错误: ssh 命令未找到,无法备份")
|
||
return 1
|
||
if completed.returncode:
|
||
print(f"错误: 远程备份失败,退出码: {completed.returncode};已中止同步")
|
||
return completed.returncode
|
||
print("-" * 60)
|
||
return 0
|
||
|
||
|
||
def sync_directory(info: dict) -> int:
|
||
backup_rc = _backup_remote(info)
|
||
if backup_rc:
|
||
return backup_rc
|
||
if shutil.which("rsync"):
|
||
return _sync_rsync(info)
|
||
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
|
||
return _sync_scp(info)
|
||
|
||
|
||
def _sync_rsync(info: dict) -> int:
|
||
source_dir = info["service_dir"]
|
||
node = info["node"]
|
||
remote_path = f"{node}:{info['remote_dir']}"
|
||
|
||
rsync_cmd = [
|
||
"rsync",
|
||
"-avz",
|
||
"--delete",
|
||
*rsync_ssh_args(info),
|
||
]
|
||
for item in info["sync_exclude"]:
|
||
rsync_cmd.append(f"--exclude={item}/")
|
||
|
||
rsync_cmd.extend([f"{source_dir}/", remote_path])
|
||
|
||
print(f"正在同步 {source_dir} 到 {remote_path}")
|
||
if info["sync_exclude"]:
|
||
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||
print(f"执行命令: {' '.join(rsync_cmd)}")
|
||
print("-" * 60)
|
||
|
||
try:
|
||
subprocess.run(rsync_cmd, check=True)
|
||
print("-" * 60)
|
||
print("同步完成!")
|
||
return 0
|
||
except subprocess.CalledProcessError as e:
|
||
print(f"错误: rsync 执行失败,退出码: {e.returncode}")
|
||
return 1
|
||
except FileNotFoundError:
|
||
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
|
||
return _sync_scp(info)
|
||
|
||
|
||
def collect_sync_files(source_dir: str, excludes: list[str]) -> list[Path]:
|
||
"""列出待同步文件;排除规则与 rsync `--exclude=name/` 一致(任意层级的同名目录)。"""
|
||
source = Path(source_dir)
|
||
exclude_set = set(excludes)
|
||
files: list[Path] = []
|
||
for root, dirs, names in os.walk(source):
|
||
dirs[:] = [d for d in dirs if d not in exclude_set]
|
||
for name in names:
|
||
path = Path(root) / name
|
||
if path.is_file():
|
||
files.append(path)
|
||
return files
|
||
|
||
|
||
def _sync_scp(info: dict) -> int:
|
||
source_dir = info["service_dir"]
|
||
remote_dir = info["remote_dir"]
|
||
node = info["node"]
|
||
source = Path(source_dir)
|
||
files = collect_sync_files(source_dir, info["sync_exclude"])
|
||
|
||
print(f"正在同步 {source_dir} 到 {node}:{remote_dir}")
|
||
if info["sync_exclude"]:
|
||
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||
print("-" * 60)
|
||
|
||
remote_dirs = {remote_dir}
|
||
grouped: dict[str, list[Path]] = {}
|
||
for path in files:
|
||
parent = path.relative_to(source).parent.as_posix()
|
||
grouped.setdefault(parent, []).append(path)
|
||
if parent != ".":
|
||
remote_dirs.add(f"{remote_dir}/{parent}")
|
||
|
||
mkdir_remote = "mkdir -p " + " ".join(shlex.quote(d) for d in sorted(remote_dirs))
|
||
mkdir_cmd = ssh_cmd(info, mkdir_remote)
|
||
print(f"执行命令: {' '.join(mkdir_cmd)}")
|
||
try:
|
||
completed = subprocess.run(mkdir_cmd, check=False)
|
||
except FileNotFoundError:
|
||
print("错误: ssh 命令未找到")
|
||
return 1
|
||
if completed.returncode:
|
||
print(f"错误: 远程 mkdir 失败,退出码: {completed.returncode}")
|
||
return completed.returncode
|
||
|
||
if not files:
|
||
print("没有需要同步的文件")
|
||
print("-" * 60)
|
||
print("同步完成!")
|
||
return 0
|
||
|
||
try:
|
||
for parent, group in sorted(grouped.items()):
|
||
dest = remote_dir if parent == "." else f"{remote_dir}/{parent}"
|
||
scp_cmd = [
|
||
*scp_base_args(info),
|
||
"-p",
|
||
"--",
|
||
*[str(p) for p in group],
|
||
f"{node}:{dest}/",
|
||
]
|
||
print(f"执行命令: {' '.join(scp_cmd)}")
|
||
subprocess.run(scp_cmd, check=True)
|
||
except subprocess.CalledProcessError as e:
|
||
print(f"错误: scp 执行失败,退出码: {e.returncode}")
|
||
return 1
|
||
except FileNotFoundError:
|
||
print("错误: scp 命令未找到,请确保已安装 openssh-client")
|
||
return 1
|
||
|
||
print("-" * 60)
|
||
print("同步完成!")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="将指定目录同步到远程机器",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
python sync.py hosts/web1/myapp
|
||
python sync.py infra/traefik --base-path /opt/app
|
||
""",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"directory",
|
||
help="要同步的目录路径(相对项目根,例如: hosts/web1/myapp)",
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--base-path",
|
||
default=DEFAULT_BASE_PATH,
|
||
help=f"远程基础路径(默认: {DEFAULT_BASE_PATH})",
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
info = service_info(args.directory)
|
||
if args.base_path != DEFAULT_BASE_PATH:
|
||
name = info["name"]
|
||
info["base_path"] = args.base_path
|
||
info["remote_dir"] = f"{args.base_path}/{name}"
|
||
|
||
print(f"目标节点: {info['node']}")
|
||
if info.get("port") is not None:
|
||
print(f"SSH 端口: {info['port']}")
|
||
if info.get("identity_file"):
|
||
print(f"SSH 密钥: {info['identity_file']}")
|
||
print(f"源目录: {args.directory}")
|
||
print(f"远程路径: {info['remote_dir']}")
|
||
print()
|
||
|
||
return sync_directory(info)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|