#!/usr/bin/env python3 """为执行域 doc_id 生成最小闭环骨架:docs + ansible/files + verify playbook。 符合 Epic 1 Story 1.3:默认通过 verify_common noop 任务链接入集群基线断言。 """ from __future__ import annotations import argparse import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent DOCS_DIR = ROOT / "docs" FILES_BASE = ROOT / "ansible" / "files" VERIFY_DIR = ROOT / "ansible" / "playbooks" / "verify" EXEC_ID_RE = re.compile(r"^(0[1-9]|[1-9][0-9])-(0[1-9]|[1-9][0-9])$") def doc_markdown(doc_id: str, slug: str, title: str) -> str: return f"""# {title} > **doc_id**:`{doc_id}` · 执行域文档(参与 `verify.sh list/full`)。 ## 契约与真源 - **清单真源目录**:`ansible/files/{doc_id}/`(不要在本文重复粘贴大块 YAML 作为第二真源)。 - **Ansible 验证入口**:`ansible/playbooks/verify/{doc_id}.yml` - 基线断言复用 `ansible/roles/verify_common/tasks/noop-doc-verify.yml`(集群连通 + 可选 manifest dry-run)。 验收命令: ```bash ./ansible/bin/verify.sh run {doc_id} ``` ## 正文 (编写步骤说明;引用清单时请写相对于仓库的路径,例如 `ansible/files/{doc_id}/demo.yaml`。) """ def files_readme(doc_id: str) -> str: return f"""# {doc_id} 清单真源 将本篇相关的 Kubernetes YAML、Helm values 等放在此目录。 - 命名建议:小写 + 连字符,例如 `app-deploy.yaml`。 - 示例或非集群清单可使用 `*.example.yaml` 后缀;noop 验证会跳过这类文件的 `kubectl dry-run`。 """ def verify_playbook(doc_id: str, doc_filename: str) -> str: return f"""--- - name: "{doc_id} noop verify (scaffold)" hosts: localhost gather_facts: false vars: repo_root: "{{{{ playbook_dir }}}}/../../.." doc_id: "{doc_id}" doc_filename: "{doc_filename}" tasks: - name: Include noop doc verify role tasks ansible.builtin.include_role: name: verify_common tasks_from: noop-doc-verify.yml """ def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="生成执行域 doc_id 最小骨架:docs、ansible/files//、verify playbook。" ) p.add_argument("doc_id", help="如 04-15(XX-YY,XX>0 且 YY>0)") p.add_argument("slug", help="文档文件名段,如 k3s-traefik-acme(生成 docs/-.md)") p.add_argument("--title", default="", help="文档 H1 标题(默认从 slug 生成)") p.add_argument("--dry-run", action="store_true", help="只打印将创建的路径,不写盘") p.add_argument("--force", action="store_true", help="覆盖已存在的同名目标文件") return p.parse_args() def validate_slug(slug: str) -> None: if not slug or len(slug) > 200: print("ERR: slug 长度须在 1~200 之间", file=sys.stderr) sys.exit(2) if slug.strip() != slug: print("ERR: slug 首尾不应含空白", file=sys.stderr) sys.exit(2) if "/" in slug or "\\" in slug or slug.startswith("."): print("ERR: slug 不允许路径分隔符或以 '.' 开头", file=sys.stderr) sys.exit(2) def main() -> None: args = parse_args() doc_id = args.doc_id.strip() slug = args.slug.strip() if not EXEC_ID_RE.fullmatch(doc_id): print(f"ERR: doc_id 非法(须匹配执行域 XX-YY):{doc_id!r}", file=sys.stderr) sys.exit(2) validate_slug(slug) doc_filename = f"{doc_id}-{slug}.md" title = args.title.strip() or slug.replace("-", " ").replace("_", " ") target_doc = DOCS_DIR / doc_filename target_pb = VERIFY_DIR / f"{doc_id}.yml" files_dir = FILES_BASE / doc_id target_readme = files_dir / "README.md" # 同一 doc_id 仅允许一篇 docs/-*.md(与 validate_matrix 一致) siblings = sorted(DOCS_DIR.glob(f"{doc_id}-*.md")) for p in siblings: if p.name != doc_filename: print( f"ERR: doc_id={doc_id} 已存在其他文档 {p.relative_to(ROOT)}," f"请先删除或合并,不能新增第二篇。", file=sys.stderr, ) sys.exit(2) planned = [ ("docs", target_doc, doc_markdown(doc_id, slug, title)), ("files README", target_readme, files_readme(doc_id)), ("verify playbook", target_pb, verify_playbook(doc_id, doc_filename)), ] for label, path, content in planned: if path.exists() and not args.force: print(f"ERR: 已存在(使用 --force 覆盖):{path.relative_to(ROOT)}", file=sys.stderr) sys.exit(2) if args.dry_run: print("[dry-run] 将创建:") for label, path, _ in planned: print(f" - {label}: {path.relative_to(ROOT)}") return for label, path, content in planned: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") print(f"[OK] wrote {path.relative_to(ROOT)}") print("") print("下一步:编辑正文与清单,然后执行:") print(f" ./scripts/offline-check.sh") print(f" ./ansible/bin/verify.sh run {doc_id}") if __name__ == "__main__": main()