#!/usr/bin/env python3 """ 远程分发并安装 deb 包。 目标 两种写法: 1. 仓库内目录(含其祖先的 _config.yaml,解析 node/port/identity_file), 例如 hosts/web1 或 hosts/web1/myapp —— 与 sync.py/remote.py 的目标语义一致。 2. 裸 SSH 别名或 user@host(须在 ~/.ssh/config 中),可配合 --port/--identity。 用法: python deb.py apt # 远程 wget 下载 + apt 安装 python deb.py push <本地.deb> [...] # scp 上传 + dpkg 安装(失败自动 apt -f 修依赖) python deb.py scp <本地.deb> [...] # 仅上传到远程暂存目录 python deb.py dpkg # 安装该节点暂存目录里已有的包 选项: --yes apt/dpkg 免交互(-y / --assume-yes) --staging-dir 远程暂存目录;默认目录目标用 {base_path}/.debs,裸主机用 /tmp/deployer-debs 示例: python deb.py hosts/web1/apt-proxy push ./gpt-load_1.0_amd64.deb python deb.py deploy@web1 apt https://example.com/pkg.deb --yes """ from __future__ import annotations import argparse import os import shlex import subprocess import sys from pathlib import Path from lib import ( project_root, run_remote, scp_base_args, service_info, ssh_config_hosts, ) BARE_HOST_STAGING = "/tmp/deployer-debs" def apt_prefix() -> str: """root 直接跑 apt/dpkg,否则加 sudo -n(免密 sudo)。""" return 'APT="apt-get"; [ "$(id -u)" = 0 ] || APT="sudo -n apt-get"' class Target: """安装目标:仓库内服务目录继承配置,或 ~/.ssh/config 中的裸主机别名。""" def __init__(self, spec: str, port: int | None, identity: str | None, staging: str) -> None: self.spec = spec abs_dir = (project_root() / spec).resolve() if abs_dir.is_dir(): self.info = service_info(spec) # 暂存目录只跟节点走(base_path/.debs),同一节点共享一个暂存区, # scp 与 dpkg 分步操作时才不会因目录不同而对不上 default_staging = f"{self.info['base_path']}/.debs" else: host = spec.split("@", 1)[-1] if host not in ssh_config_hosts(): print( f"错误: {spec!r} 既不是仓库内的服务/主机目录," f"也不是 ~/.ssh/config 中的 Host 别名" ) sys.exit(1) self.info = { "node": spec, "port": port, "identity_file": identity, } default_staging = BARE_HOST_STAGING self.staging_dir = staging or default_staging @property def node(self) -> str: return self.info["node"] def ssh(self, script: str) -> int: return run_remote(self.info, script) def upload(self, local_paths: list[str]) -> list[str]: """scp 上传文件到暂存目录,返回远程侧文件名列表。""" names = [] for p in local_paths: name = Path(p).name if not name.endswith(".deb"): print(f"错误: 不是 .deb 文件: {p}") sys.exit(1) names.append(name) remote = f"{self.node}:{shlex.quote(self.staging_dir)}/" cmd = [*scp_base_args(self.info), *local_paths, remote] print(f"正在上传到 {self.node}:{self.staging_dir}") print(f"$ {' '.join(cmd)}") print("-" * 60) try: subprocess.run(cmd, check=True) except subprocess.CalledProcessError as e: print(f"错误: scp 上传失败,退出码: {e.returncode}") sys.exit(e.returncode or 1) except FileNotFoundError: print("错误: scp 命令未找到") sys.exit(1) return names def install_staged(self, names: list[str], *, assume_yes: bool) -> int: """在远程对暂存目录里的指定包执行 apt 安装(失败自动 -f 修依赖)。""" quoted = " ".join(shlex.quote(f"{self.staging_dir}/{n}") for n in names) yes = " -y" if assume_yes else "" script = "\n".join( [ "set -e", f"mkdir -p {shlex.quote(self.staging_dir)}", apt_prefix(), f"$APT install{yes} {quoted} || $APT install{yes} -f", ] ) return self.ssh(script) def _quote(value: str) -> str: return "'" + value.replace("'", "'\\''") + "'" def action_apt(target: Target, deb: str, *, assume_yes: bool) -> int: """远程从 URL 安装:wget 到暂存目录后 apt-get install。""" if not deb.startswith(("http://", "https://", "ftp://")): print(f"提示: {deb!r} 不是 URL,请改用 push 上传本地包") return 2 filename = deb.rstrip("/").rsplit("/", 1)[-1] staged = f"{target.staging_dir}/{filename}" yes = " -y" if assume_yes else "" script = "\n".join( [ "set -e", f"mkdir -p {shlex.quote(target.staging_dir)}", apt_prefix(), f"wget -qO {_quote(staged)}.part {_quote(deb)}", f"mv {_quote(staged)}.part {_quote(staged)}", f"$APT install{yes} {_quote(staged)}", f"rm -f {_quote(staged)}", ] ) return target.ssh(script) def main() -> int: parser = argparse.ArgumentParser(description="向远程节点分发并安装 deb 包") parser.add_argument("target", help="仓库内目录(hosts/web1[/svc])或 SSH 别名(user@host)") parser.add_argument("action", choices=["apt", "push", "scp", "dpkg"]) parser.add_argument("packages", nargs="*", help="apt 的 .deb URL;push/scp 的本地 .deb 文件") parser.add_argument("--yes", action="store_true", help="apt/dpkg 免交互(-y / --assume-yes)") parser.add_argument("--staging-dir", default="", help="远程暂存目录,默认 {base_path}/.debs") parser.add_argument("--port", type=int, help="SSH 端口(仅裸主机目标)") parser.add_argument("--identity", help="SSH 私钥路径(仅裸主机目标)") args = parser.parse_args() os.chdir(project_root()) target = Target(args.target, args.port, args.identity, args.staging_dir) if args.action == "apt": if len(args.packages) != 1: parser.error("apt 需要恰好一个 .deb URL") return action_apt(target, args.packages[0], assume_yes=args.yes) if args.action in ("push", "scp"): if not args.packages: parser.error(f"{args.action} 至少需要一个本地 .deb 文件") missing = [p for p in args.packages if not Path(p).is_file()] if missing: print(f"错误: 本地文件不存在: {', '.join(missing)}") return 1 names = target.upload(args.packages) if args.action == "scp": print(f"已上传: {', '.join(names)} -> {target.node}:{target.staging_dir}") print("后续安装: python deb.py {} dpkg".format(args.target)) return 0 rc = target.install_staged(names, assume_yes=args.yes) if rc == 0: staged = " ".join(shlex.quote(f"{target.staging_dir}/{n}") for n in names) target.ssh(f"rm -f {staged}") return rc # dpkg:安装暂存目录中已上传的全部包 yes = " -y" if args.yes else "" glob = shlex.quote(f"{target.staging_dir}") + "/*.deb" script = "\n".join( [ apt_prefix(), f"ls -1 {glob} >/dev/null 2>&1 || {{ echo '错误: {target.node}:{target.staging_dir} 没有暂存的 .deb'; exit 1; }}", f"$APT install{yes} {glob} || $APT install{yes} -f", ] ) return target.ssh(script) if __name__ == "__main__": sys.exit(main())