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:
@@ -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 的镜像版本
|
||||
|
||||
@@ -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 要求拉起/重布项目测试环境时使用。
|
||||
---
|
||||
|
||||
# deployer:Compose 节点与 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 compose:up/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/
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user