feat(deployer): sync 前备份远程服务目录

rsync --delete 会清掉目标目录里的多余文件;先拷到 $HOME/bakeup/deployer-skill,失败则中止同步。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 23:45:45 +08:00
parent 130dcc36c5
commit 86ce602b28
5 changed files with 64 additions and 5 deletions
+3 -2
View File
@@ -84,8 +84,9 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。
数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、
清理远程文件的操作会先向你确认。
每次 sync 前会把远程服务目录拷到该机 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,每服务只留最近 5 份;
备份失败则中止同步。数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。
涉及删除数据卷、清理远程文件的操作会先向你确认。
## 如何判断完成
+1 -1
View File
@@ -27,7 +27,7 @@ description: >-
## 边界
- 所有 sync/up/recreate/upgrade/restart 必须按单服务执行,禁止节点级批量。
- `rsync` 带 `--delete`:运行时数据必须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。
- `rsync` 带 `--delete`:sync 前先把远程服务目录拷到 `$HOME/bakeup/deployer-skill/<name>/<时间戳>/`(每服务留 5 份);运行时数据仍须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。
- 仓库只放静态配置;证书、数据库、上传文件不进 Git,也不进同步范围。`unused/` 不参与发现与部署。
- `node` 是 `~/.ssh/config` 的 Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。
- 密钥不入库。Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。
+2 -2
View File
@@ -92,7 +92,7 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
脚本一律 `python3 -I -S <skill-dir>/scripts/deploy/<name>.py`。有 `make deploy TGT=` 才用 Makefile;没有就直接调脚本,不要读 Makefile 去发明封装。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。同步前先把远程服务目录拷到该机 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`(每服务保留最近 5 份,备份失败则中止)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
## 新增服务 / 环境
@@ -111,7 +111,7 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
2. 每个环境写 `compose.yaml`;共享的 node/base_path 放 `.pouch/deployer/_config.yaml`
3. 环境有差异 → 在该环境的 `_config.yaml` 覆盖
4. 同名冲突或需要固定远程目录名 → `_config.yaml` 写 `name:`
5. 首次部署前确认目标主机的远程目录不存在旧内容(rsync `--delete` 会清掉)
5. 首次部署:远程目录不存在或为空时跳过备份;已有内容会被拷到 `$HOME/bakeup/deployer-skill/` 再同步
## 下线服务
@@ -95,6 +95,7 @@ sync_exclude:
```
运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker/<svc>/`),避免被 `--delete` 清掉。
sync 前的配置备份在远程 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,不在同步范围内。
---
+57
View File
@@ -12,12 +12,16 @@ 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))
@@ -25,7 +29,60 @@ if str(_SCRIPT_DIR) not in sys.path:
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(不会删除远程多余文件)")