feat(deployer): fall back to scp when rsync is missing
This commit is contained in:
@@ -33,7 +33,7 @@ pouch add deployer -g # 全局
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- Compose:本机 Python 3、`rsync`、`ssh`;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
|
||||
- Compose:本机 Python 3、`ssh`;有 `rsync` 更好,没有则 sync 用 `scp` 兜底;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
|
||||
- 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖)
|
||||
- Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。
|
||||
|
||||
@@ -73,7 +73,7 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
|
||||
项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置
|
||||
2. 目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
|
||||
3. 否则用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启)
|
||||
3. 否则用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启)
|
||||
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
|
||||
5. 同步后查看容器状态和日志确认生效
|
||||
6. 只针对你指定的那一个服务操作,不会批量动整台机器
|
||||
@@ -83,8 +83,9 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
|
||||
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
|
||||
|
||||
重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据
|
||||
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。
|
||||
重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。
|
||||
数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、
|
||||
清理远程文件的操作会先向你确认。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
|
||||
|
||||
已具备: …
|
||||
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||
工具链: ssh / rsync(缺则怎么装,不擅自安装)
|
||||
工具链: ssh / rsync(无 rsync 时 sync 用 scp 兜底,不擅自安装)
|
||||
下一步: 一句话
|
||||
```
|
||||
|
||||
|
||||
@@ -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 时 tar over SSH 兜底。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
|
||||
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
|
||||
|
||||
## 新增服务 / 环境
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ def check_toolchain(report: Report) -> None:
|
||||
rsync_ok = shutil.which("rsync") is not None
|
||||
lines = [
|
||||
f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}",
|
||||
f"rsync: {'found' if rsync_ok else 'MISSING (tar-over-SSH fallback)'}",
|
||||
f"rsync: {'found' if rsync_ok else 'MISSING (scp fallback)'}",
|
||||
]
|
||||
if not ssh_ok:
|
||||
lines.append("install openssh-client")
|
||||
|
||||
@@ -22,14 +22,14 @@ _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, project_root, rsync_ssh_args, service_info, ssh_cmd # noqa: E402
|
||||
from lib import DEFAULT_BASE_PATH, rsync_ssh_args, scp_base_args, service_info, ssh_cmd # noqa: E402
|
||||
|
||||
|
||||
def sync_directory(info: dict) -> int:
|
||||
if shutil.which("rsync"):
|
||||
return _sync_rsync(info)
|
||||
print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)")
|
||||
return _sync_tar(info)
|
||||
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
|
||||
return _sync_scp(info)
|
||||
|
||||
|
||||
def _sync_rsync(info: dict) -> int:
|
||||
@@ -63,42 +63,81 @@ def _sync_rsync(info: dict) -> int:
|
||||
print(f"错误: rsync 执行失败,退出码: {e.returncode}")
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print("错误: rsync 命令未找到,请确保已安装 rsync")
|
||||
return 1
|
||||
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
|
||||
return _sync_scp(info)
|
||||
|
||||
|
||||
def _sync_tar(info: dict) -> int:
|
||||
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"]
|
||||
tar_cmd = ["tar", "czf", "-", "-C", source_dir]
|
||||
for item in info["sync_exclude"]:
|
||||
tar_cmd.append(f"--exclude={item}")
|
||||
tar_cmd.append(".")
|
||||
node = info["node"]
|
||||
source = Path(source_dir)
|
||||
files = collect_sync_files(source_dir, info["sync_exclude"])
|
||||
|
||||
remote_shell = (
|
||||
f"mkdir -p {shlex.quote(remote_dir)} && "
|
||||
f"tar xzf - -C {shlex.quote(remote_dir)}"
|
||||
)
|
||||
ssh = ssh_cmd(info, remote_shell)
|
||||
print(f"正在同步 {source_dir} 到 {info['node']}:{remote_dir}")
|
||||
print(f"正在同步 {source_dir} 到 {node}:{remote_dir}")
|
||||
if info["sync_exclude"]:
|
||||
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||||
print(f"执行命令: tar | {' '.join(ssh)}")
|
||||
print("-" * 60)
|
||||
|
||||
tar = subprocess.Popen(tar_cmd, stdout=subprocess.PIPE)
|
||||
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(ssh, stdin=tar.stdout, check=False)
|
||||
finally:
|
||||
if tar.stdout:
|
||||
tar.stdout.close()
|
||||
tar.wait()
|
||||
if tar.returncode:
|
||||
print(f"错误: tar 打包失败,退出码: {tar.returncode}")
|
||||
return tar.returncode
|
||||
completed = subprocess.run(mkdir_cmd, check=False)
|
||||
except FileNotFoundError:
|
||||
print("错误: ssh 命令未找到")
|
||||
return 1
|
||||
if completed.returncode:
|
||||
print(f"错误: 远程 tar 解包失败,退出码: {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
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEPLOY_SCRIPTS = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy"
|
||||
SYNC_PY = DEPLOY_SCRIPTS / "sync.py"
|
||||
|
||||
if str(DEPLOY_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(DEPLOY_SCRIPTS))
|
||||
|
||||
sync_spec = importlib.util.spec_from_file_location("deployer_sync", SYNC_PY)
|
||||
assert sync_spec is not None and sync_spec.loader is not None
|
||||
deployer_sync = importlib.util.module_from_spec(sync_spec)
|
||||
sync_spec.loader.exec_module(deployer_sync)
|
||||
|
||||
|
||||
def _info(service_dir: str, **overrides) -> dict:
|
||||
info = {
|
||||
"service_dir": service_dir,
|
||||
"node": "edge",
|
||||
"remote_dir": "/opt/app/svc",
|
||||
"sync_exclude": ["data", "_data"],
|
||||
"port": None,
|
||||
"identity_file": None,
|
||||
}
|
||||
info.update(overrides)
|
||||
return info
|
||||
|
||||
|
||||
class DeployerSyncTests(unittest.TestCase):
|
||||
def test_collect_sync_files_skips_excluded_directories(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
src = Path(temp)
|
||||
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
|
||||
(src / "data").mkdir()
|
||||
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
|
||||
nested = src / "cfg"
|
||||
nested.mkdir()
|
||||
(nested / "app.env").write_text("x\n", encoding="utf-8")
|
||||
(nested / "data").mkdir()
|
||||
(nested / "data" / "cache").write_text("nope\n", encoding="utf-8")
|
||||
|
||||
files = {
|
||||
p.relative_to(src).as_posix()
|
||||
for p in deployer_sync.collect_sync_files(str(src), ["data", "_data"])
|
||||
}
|
||||
self.assertEqual(files, {"compose.yaml", "cfg/app.env"})
|
||||
|
||||
def test_prefers_rsync_when_available(self) -> None:
|
||||
info = _info("/tmp/svc")
|
||||
completed = subprocess.CompletedProcess(["rsync"], 0)
|
||||
with mock.patch.object(deployer_sync.shutil, "which", return_value="/usr/bin/rsync"):
|
||||
with mock.patch.object(
|
||||
deployer_sync.subprocess, "run", return_value=completed
|
||||
) as run:
|
||||
rc = deployer_sync.sync_directory(info)
|
||||
self.assertEqual(rc, 0)
|
||||
cmd = run.call_args[0][0]
|
||||
self.assertEqual(cmd[0], "rsync")
|
||||
self.assertIn("--delete", cmd)
|
||||
self.assertIn("--exclude=data/", cmd)
|
||||
|
||||
def test_falls_back_to_scp_when_rsync_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
src = Path(temp)
|
||||
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
|
||||
(src / "data").mkdir()
|
||||
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
|
||||
nested = src / "cfg"
|
||||
nested.mkdir()
|
||||
(nested / "app.env").write_text("x\n", encoding="utf-8")
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
commands.append(list(cmd))
|
||||
return subprocess.CompletedProcess(cmd, 0)
|
||||
|
||||
info = _info(str(src), port=2222, identity_file="/tmp/key")
|
||||
with mock.patch.object(deployer_sync.shutil, "which", return_value=None):
|
||||
with mock.patch.object(
|
||||
deployer_sync.subprocess, "run", side_effect=fake_run
|
||||
):
|
||||
rc = deployer_sync.sync_directory(info)
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertTrue(commands)
|
||||
mkdir_cmd = commands[0]
|
||||
self.assertEqual(mkdir_cmd[0], "ssh")
|
||||
self.assertIn("-p", mkdir_cmd)
|
||||
self.assertIn("2222", mkdir_cmd)
|
||||
self.assertTrue(any("/opt/app/svc/cfg" in part for part in mkdir_cmd))
|
||||
|
||||
scp_cmds = [c for c in commands if c and c[0] == "scp"]
|
||||
self.assertEqual(len(scp_cmds), 2)
|
||||
joined = "\n".join(" ".join(c) for c in scp_cmds)
|
||||
self.assertIn("compose.yaml", joined)
|
||||
self.assertIn("app.env", joined)
|
||||
self.assertNotIn("db", joined)
|
||||
self.assertNotIn("--delete", joined)
|
||||
self.assertIn("-P", scp_cmds[0])
|
||||
self.assertIn("2222", scp_cmds[0])
|
||||
self.assertIn("-i", scp_cmds[0])
|
||||
self.assertIn("/tmp/key", scp_cmds[0])
|
||||
|
||||
def test_rsync_filenotfound_falls_back_to_scp(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
src = Path(temp)
|
||||
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd and cmd[0] == "rsync":
|
||||
raise FileNotFoundError("rsync")
|
||||
commands.append(list(cmd))
|
||||
return subprocess.CompletedProcess(cmd, 0)
|
||||
|
||||
with mock.patch.object(
|
||||
deployer_sync.shutil, "which", return_value="/usr/bin/rsync"
|
||||
):
|
||||
with mock.patch.object(
|
||||
deployer_sync.subprocess, "run", side_effect=fake_run
|
||||
):
|
||||
rc = deployer_sync.sync_directory(_info(str(src)))
|
||||
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertTrue(any(c and c[0] == "scp" for c in commands))
|
||||
|
||||
def test_docs_say_scp_fallback_not_tar(self) -> None:
|
||||
compose = (
|
||||
REPO_ROOT / "skills" / "deployer" / "references" / "compose.md"
|
||||
).read_text(encoding="utf-8")
|
||||
check = (DEPLOY_SCRIPTS / "check.py").read_text(encoding="utf-8")
|
||||
self.assertIn("本机无 rsync 时 scp 兜底", compose)
|
||||
self.assertNotIn("本机无 rsync 时 tar over SSH 兜底", compose)
|
||||
self.assertIn("scp fallback", check)
|
||||
self.assertNotIn("tar-over-SSH", check)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user