Files
laily f3cd56b78e feat: rename skills/skiff to pouch and move ACK state under .pouch
Use ~/.pouch, the pouch CLI, and .pouch.yaml as the SSOT container.
Keep the inner skills/ packages, and store ACK project state in
.pouch/ack instead of docs/ack.
2026-08-25 15:20:02 +08:00

366 lines
12 KiB
Python
Executable File
Raw Permalink 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
"""deployer 部署工具公共模块:解析部署根、服务目录与 _config.yaml。
支持两种布局:
1. 独立配置中心仓库(DEPLOYER_ROOT 指向,或 skill 安装位置)
2. 项目内环境目录 .pouch/deployer/{prod,test,dev}/(从 CWD 自动发现;兼容 .skiff/deployer
"""
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
_PROJECT_LAYOUT_DIRS = (".pouch", ".skiff")
def _find_project_root() -> Path:
"""部署根:DEPLOYER_ROOT > 从 CWD 向上找 .pouch/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:
for dirname in _PROJECT_LAYOUT_DIRS:
cand = cur / dirname / "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:
"""部署根是否为某项目内的 .pouch/deployer/。"""
root = root or project_root()
return root.name == "deployer" and root.parent.name in _PROJECT_LAYOUT_DIRS
def project_display_name(root: Path | None = None) -> str:
"""项目名:git 仓库名优先,否则 .pouch 的父目录名。"""
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(如 .pouch/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 any(part in _PROJECT_LAYOUT_DIRS for part 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