#!/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 scp_base_args(info: dict) -> list[str]: """scp 可执行文件及 -P / -i 选项(注意 scp 的端口是大写 P)。""" args = ["scp"] 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 run_remote(info: dict, remote_command: str, *, remote_cwd: str | None = None) -> int: """在远程节点执行 shell 命令并返回退出码;remote_cwd 提供时先切换目录。""" shell = f"cd {remote_cwd} && {remote_command}" if remote_cwd else remote_command endpoint = info["node"] if info.get("port") is not None: endpoint += f":{info['port']}" location = f"{endpoint} {remote_cwd}" if remote_cwd else endpoint print(f"远程执行: {location}") print(f"$ {remote_command}") print("-" * 60) try: return subprocess.run(ssh_cmd(info, shell), check=False).returncode except FileNotFoundError: print("错误: ssh 命令未找到") return 1 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