Files
.pouch/skills/deployer/scripts/deploy/deb.py
T

202 lines
7.7 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
远程分发并安装 deb 包。
目标 <target> 两种写法:
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 <target> apt <deb-URL> # 远程 wget 下载 + apt 安装
python deb.py <target> push <本地.deb> [...] # scp 上传 + dpkg 安装(失败自动 apt -f 修依赖)
python deb.py <target> scp <本地.deb> [...] # 仅上传到远程暂存目录
python deb.py <target> 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 URLpush/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())