feat: add deployer
This commit is contained in:
Executable
+334
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
"""deployer 部署工具公共模块:解析部署根、服务目录与 _config.yaml。
|
||||
|
||||
支持两种布局:
|
||||
1. 独立配置中心仓库(DEPLOYER_ROOT 指向,或 skill 安装位置)
|
||||
2. 项目内环境目录 .skiff/deployer/{prod,test,dev}/(从 CWD 自动发现)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_BASE_PATH = "/opt/app"
|
||||
DEFAULT_SYNC_EXCLUDES = ("data", "_data")
|
||||
_SKILL_DIR = Path(__file__).resolve().parent.parent # scripts/
|
||||
PROJECT_ROOT: Path | None = None
|
||||
|
||||
|
||||
def _find_project_root() -> Path:
|
||||
"""部署根:DEPLOYER_ROOT > 从 CWD 向上找 .skiff/deployer > skill 安装位置。"""
|
||||
env = os.environ.get("DEPLOYER_ROOT", "").strip()
|
||||
if env:
|
||||
p = Path(env).expanduser().resolve()
|
||||
if not p.is_dir():
|
||||
print(f"错误: DEPLOYER_ROOT 不是目录: {p}")
|
||||
sys.exit(1)
|
||||
return p
|
||||
cur = Path.cwd()
|
||||
while True:
|
||||
cand = cur / ".skiff" / "deployer"
|
||||
if cand.is_dir():
|
||||
return cand
|
||||
if cur == cur.parent:
|
||||
break
|
||||
cur = cur.parent
|
||||
return _SKILL_DIR.parent
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
global PROJECT_ROOT
|
||||
if PROJECT_ROOT is None:
|
||||
PROJECT_ROOT = _find_project_root()
|
||||
return PROJECT_ROOT
|
||||
|
||||
|
||||
def in_project_layout(root: Path | None = None) -> bool:
|
||||
"""部署根是否为某项目内的 .skiff/deployer/。"""
|
||||
root = root or project_root()
|
||||
return root.name == "deployer" and root.parent.name == ".skiff"
|
||||
|
||||
|
||||
def project_display_name(root: Path | None = None) -> str:
|
||||
"""项目名:git 仓库名优先,否则 .skiff 的父目录名。"""
|
||||
root = root or project_root()
|
||||
anchor = root.parent.parent if in_project_layout(root) else root
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "-C", str(anchor), "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return Path(proc.stdout.strip()).name
|
||||
except OSError:
|
||||
pass
|
||||
return anchor.name
|
||||
|
||||
|
||||
def load_config(config_path: os.PathLike | str, *, required: bool = True) -> dict:
|
||||
"""解析精简版 _config.yaml(仅支持本项目使用的字段)。"""
|
||||
config: dict = {}
|
||||
current_list_key: str | None = None
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.startswith("- ") and current_list_key:
|
||||
config.setdefault(current_list_key, []).append(line[2:].strip())
|
||||
continue
|
||||
|
||||
current_list_key = None
|
||||
if ":" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not value:
|
||||
current_list_key = key
|
||||
config.setdefault(key, [])
|
||||
continue
|
||||
|
||||
config[key] = value
|
||||
except FileNotFoundError:
|
||||
if required:
|
||||
print(f"错误: 配置文件不存在: {config_path}")
|
||||
sys.exit(1)
|
||||
return {}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def config_paths_for_service(service_dir: str) -> list[Path]:
|
||||
"""收集部署根自身及服务目录各层 _config.yaml(祖先在前,服务目录在后)。
|
||||
|
||||
部署根的 _config.yaml(如 .skiff/deployer/_config.yaml)作为全局默认,
|
||||
对所有环境/服务生效。
|
||||
"""
|
||||
root = project_root()
|
||||
rel = Path(service_dir)
|
||||
paths: list[Path] = []
|
||||
root_config = root / "_config.yaml"
|
||||
if root_config.is_file():
|
||||
paths.append(root_config)
|
||||
for depth in range(1, len(rel.parts) + 1):
|
||||
config_path = root / Path(*rel.parts[:depth]) / "_config.yaml"
|
||||
if config_path.is_file() and config_path != root_config:
|
||||
paths.append(config_path)
|
||||
return paths
|
||||
|
||||
|
||||
def merge_service_config(service_dir: str) -> tuple[dict, list[Path]]:
|
||||
"""合并服务目录及其祖先的 _config.yaml,子级覆盖父级。"""
|
||||
merged: dict = {}
|
||||
sources = config_paths_for_service(service_dir)
|
||||
for config_path in sources:
|
||||
merged.update(load_config(config_path, required=True))
|
||||
return merged, sources
|
||||
|
||||
|
||||
def ssh_config_hosts() -> set[str]:
|
||||
"""读取 ~/.ssh/config 中的 Host 别名(不含通配符)。"""
|
||||
global _SSH_HOSTS
|
||||
if _SSH_HOSTS is not None:
|
||||
return _SSH_HOSTS
|
||||
|
||||
hosts: set[str] = set()
|
||||
config_path = Path.home() / ".ssh" / "config"
|
||||
if config_path.is_file():
|
||||
for raw_line in config_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if not line.lower().startswith("host "):
|
||||
continue
|
||||
for host in line.split()[1:]:
|
||||
if "*" in host or "?" in host or "!" in host:
|
||||
continue
|
||||
hosts.add(host)
|
||||
|
||||
_SSH_HOSTS = hosts
|
||||
return hosts
|
||||
|
||||
|
||||
_SSH_HOSTS: set[str] | None = None
|
||||
|
||||
|
||||
def parse_port(raw: str, *, strict: bool = True) -> int | None:
|
||||
"""解析 SSH 端口,无效时 strict 模式下退出。"""
|
||||
try:
|
||||
port = int(raw)
|
||||
except ValueError:
|
||||
if strict:
|
||||
print(f"错误: port 必须是整数: {raw!r}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
if not 1 <= port <= 65535:
|
||||
if strict:
|
||||
print(f"错误: port 超出有效范围 1-65535: {port}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
return port
|
||||
|
||||
|
||||
def resolve_identity_file(raw: str, *, strict: bool = True) -> str | None:
|
||||
"""解析 SSH 私钥路径(expanduser,须为绝对路径且文件存在)。"""
|
||||
path = os.path.expanduser(raw)
|
||||
if not os.path.isabs(path):
|
||||
if strict:
|
||||
print(f"错误: identity_file 必须是绝对路径或 ~ 开头: {raw!r}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
if not os.path.isfile(path):
|
||||
if strict:
|
||||
print(f"错误: identity_file 不存在: {path}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def ssh_base_args(info: dict) -> list[str]:
|
||||
"""ssh 可执行文件及 -p / -i 等选项(不含 host 与 remote command)。"""
|
||||
args = ["ssh"]
|
||||
port = info.get("port")
|
||||
if port is not None:
|
||||
args.extend(["-p", str(port)])
|
||||
identity_file = info.get("identity_file")
|
||||
if identity_file:
|
||||
args.extend(["-i", identity_file])
|
||||
return args
|
||||
|
||||
|
||||
def ssh_cmd(info: dict, remote_command: str) -> list[str]:
|
||||
"""构建 ssh 命令行(含可选 -p / -i)。"""
|
||||
return [*ssh_base_args(info), info["node"], remote_command]
|
||||
|
||||
|
||||
def rsync_ssh_args(info: dict) -> list[str]:
|
||||
"""rsync 需自定义 ssh 时通过 -e 传入 port / identity_file。"""
|
||||
base = ssh_base_args(info)
|
||||
if len(base) == 1:
|
||||
return []
|
||||
return ["-e", shlex.join(base)]
|
||||
|
||||
|
||||
def node_from_parent_dir(service_dir: str) -> str | None:
|
||||
"""父目录名若是 SSH Host 别名,则作为 node。"""
|
||||
parent = Path(service_dir.rstrip("/")).parent.name
|
||||
if not parent or parent == ".":
|
||||
return None
|
||||
if parent in ssh_config_hosts():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def service_info(service_dir: str, *, strict: bool = True) -> dict | None:
|
||||
"""解析单个服务目录,返回 node、远程路径等信息。"""
|
||||
root = project_root()
|
||||
abs_dir = (root / service_dir).resolve()
|
||||
if not abs_dir.is_dir():
|
||||
if strict:
|
||||
print(f"错误: 服务目录不存在: {service_dir}")
|
||||
sys.exit(1)
|
||||
return None
|
||||
|
||||
env_name = abs_dir.name
|
||||
rel_dir = str(abs_dir.relative_to(root))
|
||||
config, sources = merge_service_config(rel_dir)
|
||||
|
||||
# 远程目录名:_config.yaml 的 name 显式覆盖;项目布局默认 {项目名}-{env}
|
||||
# 防止同主机上多个项目的 prod/test 相互覆盖
|
||||
if "name" in config:
|
||||
name = str(config["name"])
|
||||
elif in_project_layout(root):
|
||||
name = f"{project_display_name(root)}-{env_name}"
|
||||
if strict:
|
||||
print(f"提示: 项目模式,远程名自动加前缀: {name!r}(_config.yaml 写 name: 可覆盖)")
|
||||
else:
|
||||
name = env_name
|
||||
|
||||
node = config.get("node")
|
||||
if not node:
|
||||
node = node_from_parent_dir(rel_dir)
|
||||
if node and strict and not config:
|
||||
print(f"提示: 未找到 _config.yaml,使用父目录 SSH 主机 {node!r}")
|
||||
|
||||
if not node:
|
||||
if strict:
|
||||
print(f"错误: 无法解析 node: {service_dir}")
|
||||
print(" 请在服务目录或其祖先目录添加 _config.yaml,或确保父目录是 SSH 主机别名")
|
||||
sys.exit(1)
|
||||
return None
|
||||
|
||||
base_path = config.get("base_path", DEFAULT_BASE_PATH)
|
||||
excludes = config.get("sync_exclude") or list(DEFAULT_SYNC_EXCLUDES)
|
||||
remote_dir = f"{base_path}/{name}"
|
||||
port = parse_port(config["port"], strict=strict) if "port" in config else None
|
||||
identity_file = (
|
||||
resolve_identity_file(config["identity_file"], strict=strict)
|
||||
if "identity_file" in config
|
||||
else None
|
||||
)
|
||||
|
||||
info = {
|
||||
"service_dir": str(abs_dir),
|
||||
"name": name,
|
||||
"node": node,
|
||||
"port": port,
|
||||
"identity_file": identity_file,
|
||||
"base_path": base_path,
|
||||
"remote_dir": remote_dir,
|
||||
"sync_exclude": excludes,
|
||||
"config_sources": [str(p.relative_to(root)) for p in sources],
|
||||
}
|
||||
local_config = abs_dir / "_config.yaml"
|
||||
if strict and sources and not local_config.is_file():
|
||||
rel_sources = info["config_sources"]
|
||||
if len(rel_sources) == 1:
|
||||
print(f"提示: 使用继承配置 {rel_sources[0]!r}")
|
||||
else:
|
||||
print(f"提示: 使用继承配置 {' -> '.join(rel_sources)!r}")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def is_deployable_dir(path: Path, root: Path) -> bool:
|
||||
if "unused" in path.parts or "__pycache__" in path.parts:
|
||||
return False
|
||||
if ".skiff" in path.parts and root.name != "deployer":
|
||||
return False
|
||||
if not (path / "compose.yaml").is_file():
|
||||
return False
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def discover_services(node: str | None = None) -> list[dict]:
|
||||
"""扫描仓库内所有带 compose.yaml 且能解析 node 的服务。"""
|
||||
root = project_root()
|
||||
services: list[dict] = []
|
||||
|
||||
for compose_path in sorted(root.glob("**/compose.yaml")):
|
||||
service_dir = compose_path.parent
|
||||
if not is_deployable_dir(service_dir, root):
|
||||
continue
|
||||
rel = str(service_dir.relative_to(root))
|
||||
info = service_info(rel, strict=False)
|
||||
if info is None:
|
||||
continue
|
||||
if node is None or info["node"] == node:
|
||||
services.append(info)
|
||||
|
||||
return services
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""列出仓库内所有可部署服务及其目标节点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
from lib import discover_services, project_root
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = project_root()
|
||||
os.chdir(root)
|
||||
services = discover_services()
|
||||
if not services:
|
||||
print("未找到任何可部署服务(需 compose.yaml 且能解析 node)")
|
||||
return 0
|
||||
|
||||
by_node: dict[str, list[str]] = defaultdict(list)
|
||||
for info in services:
|
||||
rel = os.path.relpath(info["service_dir"], root)
|
||||
by_node[info["node"]].append(rel)
|
||||
|
||||
print(f"共 {len(services)} 个服务,分布在 {len(by_node)} 个节点:\n")
|
||||
for node in sorted(by_node):
|
||||
print(f"[{node}]")
|
||||
for service in by_node[node]:
|
||||
print(f" - {service}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
在远程节点上执行 docker compose 操作。
|
||||
|
||||
用法:
|
||||
python remote.py <服务目录> <命令>
|
||||
|
||||
命令:
|
||||
up 启动/更新容器(不拉镜像)
|
||||
recreate 强制重建容器(up -d --force-recreate,改配置后用)
|
||||
restart 重启 compose 内所有服务
|
||||
upgrade 拉取镜像并重建容器
|
||||
ps 查看容器状态
|
||||
logs 查看最近日志(非 follow)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from lib import project_root, service_info, ssh_cmd
|
||||
|
||||
REMOTE_COMMANDS = {
|
||||
"up": "docker compose up -d",
|
||||
"recreate": "docker compose up -d --force-recreate",
|
||||
"restart": "docker compose restart",
|
||||
"upgrade": "docker compose pull && docker compose up -d",
|
||||
"ps": "docker compose ps",
|
||||
"logs": "docker compose logs --tail=100",
|
||||
}
|
||||
|
||||
|
||||
def run_ssh(info: dict, compose_command: str) -> int:
|
||||
remote_dir = info["remote_dir"]
|
||||
remote_shell = f"cd {remote_dir} && {compose_command}"
|
||||
cmd = ssh_cmd(info, remote_shell)
|
||||
endpoint = info["node"]
|
||||
if info.get("port") is not None:
|
||||
endpoint = f"{endpoint}:{info['port']}"
|
||||
print(f"远程执行: {endpoint}:{remote_dir}")
|
||||
print(f"$ {compose_command}")
|
||||
print("-" * 60)
|
||||
try:
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
except FileNotFoundError:
|
||||
print("错误: ssh 命令未找到")
|
||||
return 1
|
||||
|
||||
|
||||
def run_for_service(service_dir: str, action: str) -> int:
|
||||
info = service_info(service_dir)
|
||||
compose_command = REMOTE_COMMANDS[action]
|
||||
endpoint = info["node"]
|
||||
if info.get("port") is not None:
|
||||
endpoint = f"{endpoint}:{info['port']}"
|
||||
print(f"服务: {service_dir} -> {endpoint}:{info['remote_dir']}")
|
||||
return run_ssh(info, compose_command)
|
||||
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="在远程节点执行 docker compose 操作")
|
||||
parser.add_argument("target", help="服务目录,例如 hosts/web1/myapp")
|
||||
parser.add_argument(
|
||||
"action",
|
||||
choices=sorted(REMOTE_COMMANDS),
|
||||
help="远程操作",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.chdir(project_root())
|
||||
return run_for_service(args.target, args.action)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
目录同步脚本
|
||||
将指定目录同步到远程机器
|
||||
|
||||
用法:
|
||||
python sync.py <目录路径>
|
||||
例如: python sync.py hosts/web1/myapp
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from lib import DEFAULT_BASE_PATH, project_root, rsync_ssh_args, service_info, ssh_cmd
|
||||
|
||||
|
||||
def sync_directory(info: dict) -> int:
|
||||
if shutil.which("rsync"):
|
||||
return _sync_rsync(info)
|
||||
print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)")
|
||||
return _sync_tar(info)
|
||||
|
||||
|
||||
def _sync_rsync(info: dict) -> int:
|
||||
source_dir = info["service_dir"]
|
||||
node = info["node"]
|
||||
remote_path = f"{node}:{info['remote_dir']}"
|
||||
|
||||
rsync_cmd = [
|
||||
"rsync",
|
||||
"-avz",
|
||||
"--delete",
|
||||
*rsync_ssh_args(info),
|
||||
]
|
||||
for item in info["sync_exclude"]:
|
||||
rsync_cmd.append(f"--exclude={item}/")
|
||||
|
||||
rsync_cmd.extend([f"{source_dir}/", remote_path])
|
||||
|
||||
print(f"正在同步 {source_dir} 到 {remote_path}")
|
||||
if info["sync_exclude"]:
|
||||
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||||
print(f"执行命令: {' '.join(rsync_cmd)}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
subprocess.run(rsync_cmd, check=True)
|
||||
print("-" * 60)
|
||||
print("同步完成!")
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"错误: rsync 执行失败,退出码: {e.returncode}")
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print("错误: rsync 命令未找到,请确保已安装 rsync")
|
||||
return 1
|
||||
|
||||
|
||||
def _sync_tar(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(".")
|
||||
|
||||
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}")
|
||||
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)
|
||||
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
|
||||
if completed.returncode:
|
||||
print(f"错误: 远程 tar 解包失败,退出码: {completed.returncode}")
|
||||
return completed.returncode
|
||||
print("-" * 60)
|
||||
print("同步完成!")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="将指定目录同步到远程机器",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
python sync.py hosts/web1/myapp
|
||||
python sync.py infra/traefik --base-path /opt/app
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"directory",
|
||||
help="要同步的目录路径(相对项目根,例如: hosts/web1/myapp)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--base-path",
|
||||
default=DEFAULT_BASE_PATH,
|
||||
help=f"远程基础路径(默认: {DEFAULT_BASE_PATH})",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
info = service_info(args.directory)
|
||||
if args.base_path != DEFAULT_BASE_PATH:
|
||||
name = info["name"]
|
||||
info["base_path"] = args.base_path
|
||||
info["remote_dir"] = f"{args.base_path}/{name}"
|
||||
|
||||
print(f"目标节点: {info['node']}")
|
||||
if info.get("port") is not None:
|
||||
print(f"SSH 端口: {info['port']}")
|
||||
if info.get("identity_file"):
|
||||
print(f"SSH 密钥: {info['identity_file']}")
|
||||
print(f"源目录: {args.directory}")
|
||||
print(f"远程路径: {info['remote_dir']}")
|
||||
print()
|
||||
|
||||
return sync_directory(info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user