153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
#!/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/<doc_id>/、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/<doc_id>-<slug>.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/<doc_id>-*.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()
|