diff --git a/skills/deployer/README.md b/skills/deployer/README.md index 2b6e966..dbfcac5 100644 --- a/skills/deployer/README.md +++ b/skills/deployer/README.md @@ -84,8 +84,9 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR 避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。 重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。 -数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、 -清理远程文件的操作会先向你确认。 +每次 sync 前会把远程服务目录拷到该机 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,每服务只留最近 5 份; +备份失败则中止同步。数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。 +涉及删除数据卷、清理远程文件的操作会先向你确认。 ## 如何判断完成 diff --git a/skills/deployer/SKILL.md b/skills/deployer/SKILL.md index bbe3354..58ba9e7 100644 --- a/skills/deployer/SKILL.md +++ b/skills/deployer/SKILL.md @@ -27,7 +27,7 @@ description: >- ## 边界 - 所有 sync/up/recreate/upgrade/restart 必须按单服务执行,禁止节点级批量。 -- `rsync` 带 `--delete`:运行时数据必须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。 +- `rsync` 带 `--delete`:sync 前先把远程服务目录拷到 `$HOME/bakeup/deployer-skill//<时间戳>/`(每服务留 5 份);运行时数据仍须在 `data/`、`_data/` 或远程绝对路径挂载,否则会被清掉。 - 仓库只放静态配置;证书、数据库、上传文件不进 Git,也不进同步范围。`unused/` 不参与发现与部署。 - `node` 是 `~/.ssh/config` 的 Host 别名(可用 `user@host`)。用户没给别名就不要写假 node。 - 密钥不入库。Compose 优先放远程 `.env`;Argo CD 的 dockerconfigjson / TLS 私钥只存在集群 Secret。 diff --git a/skills/deployer/references/compose.md b/skills/deployer/references/compose.md index e735f79..7b60087 100644 --- a/skills/deployer/references/compose.md +++ b/skills/deployer/references/compose.md @@ -92,7 +92,7 @@ python3 /scripts/deploy/sync.py 脚本一律 `python3 -I -S /scripts/deploy/.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 /scripts/deploy/sync.py 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/` 再同步 ## 下线服务 diff --git a/skills/deployer/references/config-reference.md b/skills/deployer/references/config-reference.md index f900e6c..3195599 100644 --- a/skills/deployer/references/config-reference.md +++ b/skills/deployer/references/config-reference.md @@ -95,6 +95,7 @@ sync_exclude: ``` 运行时数据放这些目录或 compose 挂载的远程绝对路径(如 `/data01/docker//`),避免被 `--delete` 清掉。 +sync 前的配置备份在远程 `$HOME/bakeup/deployer-skill/<服务名>/<时间戳>/`,不在同步范围内。 --- diff --git a/skills/deployer/scripts/deploy/sync.py b/skills/deployer/scripts/deploy/sync.py index 9a307f5..294f7cc 100755 --- a/skills/deployer/scripts/deploy/sync.py +++ b/skills/deployer/scripts/deploy/sync.py @@ -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///,失败则中止 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(不会删除远程多余文件)")