feat: add skill init/check and isolate builder makefile

Give ack, builder, and deployer an explicit init/check mode that reports
missing project config instead of failing mid-work. Point builder at
makefile.builder so its contract targets do not collide with an existing
Makefile.
This commit is contained in:
2026-08-25 16:49:28 +08:00
parent e9d2b5fde6
commit 10d8800f07
21 changed files with 1129 additions and 95 deletions
+5
View File
@@ -12,6 +12,7 @@
## 什么时候使用
- 给新项目初始化 deployer:检查缺什么,引导补 `.pouch/deployer/`
- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器
- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
@@ -30,6 +31,10 @@
## 使用示例
```text
# 新项目
用 deployer 初始化这个项目
给这个项目接上 test 环境,SSH 别名是 my-vps
# 独立配置中心
帮我把 vyyo1/naiveproxy 的配置改完部署上去
升级 vora3/gpt-load 的镜像版本
+68 -8
View File
@@ -1,13 +1,14 @@
---
name: deployer
description: >-
管理两类部署:多机 Docker Compose(仓库存 compose.yaml 与静态配置,本 skill 脚本
同步到 SSH 节点后 docker compose 应用),以及 Argo CD GitOps(改 GitOps 仓库清单、
开 PR/MR,用户合并后由 Argo CD 同步)。当用户要求部署、同步、升级、重启远程
Compose 服务,向节点装 deb,新增/迁移/下线服务,梳理节点清单,make deploy TGT、
_config.yaml、rsync、tar over SSH、NAS 部署失败;或要求 ArgoCD / GitOps / K8s
部署、更新 Application、升镜像 tag、开 MR 让用户合并部署;或 ACK 要求拉起/
重布项目测试环境时使用。
初始化或检查项目部署配置,并管理两类部署:多机 Docker Compose(仓库存
compose.yaml 与静态配置,本 skill 脚本同步到 SSH 节点后 docker compose 应用),
以及 Argo CD GitOps(改 GitOps 仓库清单、开 PR/MR,用户合并后由 Argo CD 同步)。
当用户要求初始化 deployer、接入测试/生产环境、检查 .pouch/deployer 是否齐全;
或部署、同步、升级、重启远程 Compose 服务,向节点装 deb,新增/迁移/下线服务,
梳理节点清单,make deploy TGT、_config.yaml、rsync、tar over SSH、NAS 部署失败;
或要求 ArgoCD / GitOps / K8s 部署、更新 Application、升镜像 tag、开 MR 让用户
合并部署;或 ACK 要求拉起/重布项目测试环境时使用。
---
# deployerCompose 节点与 Argo CD GitOps
@@ -18,7 +19,16 @@ description: >-
- **Argo CD**:改 GitOps 仓库清单 → 开 PR/MR → 用户合并 → Argo CD 同步。不要用
Compose 的 `sync.py`/`remote.py` 去推集群。
---
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
`git rev-parse --show-toplevel` 解析项目根。不要创建 `.pouch/deployer/` 之外的
假配置中心,也不要用 `pouch init deployer`
## 选择模式
- 用户要求初始化、接入 deployer,或给新项目建测试/生产环境:执行“初始化”。
- 用户要求检查 `.pouch/deployer`、node、compose 是否齐全:执行“检查”。
- 用户要求部署、同步、升级、重启、装 deb、开 GitOps MR:执行下面对应轨道步骤。
发现不了服务或解析不出 node 时停止,转入“初始化”。不要静默初始化。
## 何时使用
@@ -29,6 +39,7 @@ description: >-
- 提到 `make deploy TGT=...``TGT=``_config.yaml`、rsync/tar 同步
- Argo CD / GitOps / 集群部署:新增 Application、改清单、升镜像 tag、开 MR 等用户合并
- ACK Coordinator 拉起或重布项目测试环境(`.pouch/deployer/<env>`
- 初始化 deployer、给项目接上 test/prod、检查部署配置缺什么
## 不适用
@@ -98,6 +109,54 @@ my-project/
(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖;
`_config.yaml``name:` 可显式指定。
## 初始化
独立配置中心仓库(已设 `DEPLOYER_ROOT`)只做检查,不要改成项目内布局。
1. 探测:`.pouch/deployer/`、根目录 `compose.yaml`/`docker-compose.yml`
`Dockerfile`、ACK `intents.testEnvironment``.pouch/deployer/argocd.yaml`
2. Compose 与 Argo 都有且意图不清:先问。两边都要也可以,必须分开确认。
3. **Compose / 新项目**ACK 默认需要 `test`):
- 问环境:默认只建 `test``prod`/`dev` 用户点名再加。不默默建空的 prod。
- 问 SSH Host 别名(必须在 `~/.ssh/config`)。没给就**不要写假 node**;
目录可以建,`_config.yaml` 列为待配置。
- 有根目录 compose:提议迁到 `.pouch/deployer/<env>/`,确认后才动。
- 只有 Dockerfile:可给**单服务** compose 草稿(镜像名来自仓库名),用户确认
后写入。不发明多服务网格。
4. **Argo**:只问 GitOps `repo`(或 `repo_dir`),写 `.pouch/deployer/argocd.yaml`
不 clone、不开 MR、不 `kubectl apply`
5. 运行(不 SSH、不 up):
```bash
python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
```
6. 按下面格式报告。缺 `node` / compose / ssh 别名 = 部分完成或阻塞。
不覆盖已有 compose/`_config.yaml`。除非用户明确要求,不部署、不提交。
```text
## deployer 初始化:完成 | 部分完成 | 阻塞
已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: ssh / rsync(缺则怎么装,不擅自安装)
下一步: 一句话
```
`_config.yaml` 示例(`node` 必须是用户给出的 SSH Host 别名):
```yaml
# .pouch/deployer/_config.yaml
node: my-vps
base_path: /opt/app
```
## 检查
只读。运行 `scripts/deploy/check.py --project <project-root>`,用同一报告格式,
标题改为 `## deployer 检查:…`。不写文件、不 SSH 连接。用户明确要求修复后再转入
初始化。
## 被 ACK 调用
ACK 的「运行测试环境」和回归前布环境会加载本 skill,对项目
@@ -267,6 +326,7 @@ python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1
| `scripts/deploy/remote.py` | SSH 远程 docker composeup/recreate/restart/upgrade/ps/logs |
| `scripts/deploy/deb.py` | deb 包分发安装:push(scp+apt)/scp/dpkg/apt(URL) |
| `scripts/deploy/list.py` | 扫描全部可部署服务 |
| `scripts/deploy/check.py` | 只读就绪检查:布局、compose、node、ssh config、工具链 |
## references/
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Readiness checker for deployer project layout.
Usage:
python3 -I -S check.py [--project DIR]
Resolves the deploy root as DEPLOYER_ROOT, else <project>/.pouch/deployer
(or .skiff/deployer), else walking up from cwd. Does not SSH, rsync, or
start containers. Exit 0 = PASS (SKIP allowed), 1 = FAIL, 2 = usage.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import sys
from pathlib import Path
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
import lib # noqa: E402
PASS = "PASS"
FAIL = "FAIL"
SKIP = "SKIP"
_PROJECT_LAYOUT_DIRS = (".pouch", ".skiff")
_REPO_OR_DIR = re.compile(r"^(repo|repo_dir)\s*:", re.MULTILINE)
class Report:
def __init__(self) -> None:
self.failures = 0
def add(self, status: str, number: int, title: str, detail: str) -> None:
print(f"[{status}] {number}. {title}")
for line in detail.splitlines():
print(f" {line}")
if status == FAIL:
self.failures += 1
def node_in_ssh_config(node: str, hosts: set[str]) -> bool:
if node in hosts:
return True
if "@" in node:
_, host = node.rsplit("@", 1)
return host in hosts
return False
def resolve_deploy_root(project: Path | None) -> tuple[Path | None, str]:
env = os.environ.get("DEPLOYER_ROOT", "").strip()
if env:
path = Path(env).expanduser().resolve()
return (path if path.is_dir() else None), "DEPLOYER_ROOT"
if project is not None:
root = project.resolve()
for dirname in _PROJECT_LAYOUT_DIRS:
candidate = root / dirname / "deployer"
if candidate.is_dir():
return candidate, "project"
return None, "project"
found = lib._find_project_root()
if found.name == "deployer" and found.parent.name in _PROJECT_LAYOUT_DIRS:
return found, "project"
if found == lib._SKILL_DIR.parent:
return None, "cwd"
return found, "cwd"
def compose_service_dirs(root: Path) -> list[Path]:
dirs: list[Path] = []
for compose in sorted(root.glob("**/compose.yaml")):
service_dir = compose.parent
if not lib.is_deployable_dir(service_dir, root):
continue
dirs.append(service_dir)
return dirs
def check_layout(report: Report, root: Path | None, source: str, project: Path | None) -> bool:
if root is None:
if source == "DEPLOYER_ROOT":
report.add(
FAIL,
1,
"部署根存在",
"DEPLOYER_ROOT is set but is not a directory",
)
else:
hint_root = project.resolve() if project is not None else Path.cwd()
report.add(
FAIL,
1,
"部署根存在",
"\n".join(
[
f"no .pouch/deployer under {hint_root}",
"Fix: run deployer 初始化 and create .pouch/deployer/",
" _config.yaml # node: <ssh-host-alias>",
" test/compose.yaml # default env for ACK",
]
),
)
return False
kind = "project layout" if lib.in_project_layout(root) else "standalone deploy root"
report.add(PASS, 1, "部署根存在", f"{root} ({kind}, via {source})")
return True
def check_toolchain(report: Report) -> None:
ssh_ok = shutil.which("ssh") is not None
rsync_ok = shutil.which("rsync") is not None
lines = [
f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}",
f"rsync: {'found' if rsync_ok else 'MISSING (tar-over-SSH fallback)'}",
]
if not ssh_ok:
lines.append("install openssh-client")
report.add(FAIL, 2, "工具链", "\n".join(lines))
return
report.add(PASS if rsync_ok else SKIP, 2, "工具链", "\n".join(lines))
def check_argocd(report: Report, root: Path) -> bool:
path = root / "argocd.yaml"
if not path.is_file():
report.add(SKIP, 3, "Argo CD 指针", "no argocd.yaml")
return False
text = path.read_text(encoding="utf-8")
if _REPO_OR_DIR.search(text):
report.add(PASS, 3, "Argo CD 指针", "argocd.yaml has repo or repo_dir")
return True
report.add(
FAIL,
3,
"Argo CD 指针",
"argocd.yaml exists but has neither repo: nor repo_dir:\n"
"Fix: repo: git@host:org/infra-gitops.git",
)
return True
def check_services(report: Report, root: Path) -> None:
services = compose_service_dirs(root)
if not services:
report.add(
FAIL,
4,
"至少有一个 compose.yaml",
"no compose.yaml under the deploy root\n"
"Fix: add .pouch/deployer/<env>/compose.yaml (env usually test)",
)
report.add(SKIP, 5, "每个服务能解析 node", "(no compose.yaml)")
report.add(SKIP, 6, "node 出现在 SSH config", "(no compose.yaml)")
report.add(SKIP, 7, "list 可发现服务", "(no compose.yaml)")
return
rels = [str(path.relative_to(root)) for path in services]
report.add(PASS, 4, "至少有一个 compose.yaml", "\n".join(rels))
hosts = lib.ssh_config_hosts()
node_lines = []
ssh_lines = []
node_fail = False
ssh_fail = False
for path in services:
rel = str(path.relative_to(root))
info = lib.service_info(rel, strict=False)
if info is None:
node_lines.append(f"{rel}: MISSING node")
ssh_lines.append(f"{rel}: skipped (no node)")
node_fail = True
continue
node = str(info["node"])
node_lines.append(f"{rel}: node={node}")
if node_in_ssh_config(node, hosts):
ssh_lines.append(f"{rel}: {node} in ~/.ssh/config")
else:
ssh_lines.append(f"{rel}: {node} NOT in ~/.ssh/config")
ssh_fail = True
if node_fail:
node_lines.extend(
[
"",
"Fix: write node in _config.yaml (deploy root or env dir).",
"Example:",
" node: my-vps",
" base_path: /opt/app",
"Do not invent a hostname. It must be an SSH Host alias.",
]
)
report.add(FAIL, 5, "每个服务能解析 node", "\n".join(node_lines))
else:
report.add(PASS, 5, "每个服务能解析 node", "\n".join(node_lines))
if ssh_fail or node_fail:
if ssh_fail:
ssh_lines.extend(
[
"",
"Fix: add a Host entry to ~/.ssh/config for the node alias.",
"This check does not open an SSH connection.",
]
)
report.add(FAIL, 6, "node 出现在 SSH config", "\n".join(ssh_lines))
else:
report.add(PASS, 6, "node 出现在 SSH config", "\n".join(ssh_lines))
found = lib.discover_services()
if not found:
report.add(
FAIL,
7,
"list 可发现服务",
"compose.yaml exists but discover_services found none "
"(need resolvable node)",
)
return
names = [os.path.relpath(item["service_dir"], root) for item in found]
report.add(PASS, 7, "list 可发现服务", f"{len(found)} service(s): " + ", ".join(names))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--project",
type=Path,
default=None,
help="project root (looks for .pouch/deployer); ignored when DEPLOYER_ROOT is set",
)
args = parser.parse_args(argv)
report = Report()
project = args.project.resolve() if args.project is not None else None
root, source = resolve_deploy_root(project)
layout_ok = check_layout(report, root, source, project)
check_toolchain(report)
if not layout_ok:
report.add(SKIP, 3, "Argo CD 指针", "(no deploy root)")
report.add(SKIP, 4, "至少有一个 compose.yaml", "(no deploy root)")
report.add(SKIP, 5, "每个服务能解析 node", "(no deploy root)")
report.add(SKIP, 6, "node 出现在 SSH config", "(no deploy root)")
report.add(SKIP, 7, "list 可发现服务", "(no deploy root)")
print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
assert root is not None
lib.PROJECT_ROOT = root
lib._SSH_HOSTS = None
previous_cwd = Path.cwd()
try:
os.chdir(root)
has_argocd = check_argocd(report, root)
services = compose_service_dirs(root)
if services:
check_services(report, root)
elif has_argocd:
report.add(SKIP, 4, "至少有一个 compose.yaml", "Argo CD only; no compose env")
report.add(SKIP, 5, "每个服务能解析 node", "Argo CD only")
report.add(SKIP, 6, "node 出现在 SSH config", "Argo CD only")
report.add(SKIP, 7, "list 可发现服务", "Argo CD only")
else:
check_services(report, root)
finally:
os.chdir(previous_cwd)
print()
if report.failures:
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1
print("RESULT: PASSED")
return 0
if __name__ == "__main__":
raise SystemExit(main())