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:
@@ -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