docs: add generated tutorial compatibility matrix (#212)

* docs: declare tutorial compatibility requirements

* docs: generate tutorial compatibility matrix

* fix: correct BPF Arena minimum kernel

* refactor: simplify compatibility validation

---------

Co-authored-by: LinuxDev9002 <i@littlefisher.me>
This commit is contained in:
云微
2026-07-14 05:00:48 -07:00
committed by GitHub
parent e08f604769
commit 37375ff87e
78 changed files with 1167 additions and 13 deletions

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""Validate tutorial compatibility metadata and generate compatibility tables."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
REQUIRED_FIELDS = (
"kernel_min",
"kernel_min_basis",
"architectures",
"btf",
"kernel_config",
"hardware",
"root",
"test_status",
)
ARCHITECTURES = {"all", "x86_64", "arm64"}
BTF_VALUES = {"required", "optional", "not-required"}
ROOT_VALUES = {"required", "not-required"}
TEST_STATUSES = {"ci-runtime", "ci-build", "not-in-ci", "docs-only"}
KERNEL_BASES = {
"documented",
"required-feature",
"repository-baseline",
"unverified",
"not-applicable",
}
HARDWARE_VALUES = {
"none",
"android-device-or-emulator",
"block-device",
"drm-gpu",
"gpu",
"intel-npu",
"network-interface",
"nvidia-cuda-gpu",
}
KERNEL_VERSION = re.compile(r"^\d+\.\d+$")
ARCH_KERNEL_VERSION = re.compile(r"^(x86_64|arm64):\d+\.\d+$")
KERNEL_CONFIG = re.compile(r"^CONFIG_[A-Z0-9_]+=(?:y|m|y\|m)$")
REPOSITORY_BASELINE = "4.8"
def sort_key(path: Path) -> tuple:
"""Match the natural ordering used by the tutorial indexes."""
relative = path.parent.as_posix()
numbers = tuple(map(int, re.findall(r"\d+", relative)))
return (0, numbers, relative) if numbers else (1, (), relative)
def parse_config(path: Path) -> dict[str, str]:
metadata: dict[str, str] = {}
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise ValueError(f"{path}:{line_number}: expected key=value")
key, value = line.split("=", 1)
if key in metadata:
raise ValueError(f"{path}:{line_number}: duplicate field {key}")
metadata[key] = value
return metadata
def _csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",")]
def _validate_architectures(path: Path, value: str) -> list[str]:
architectures = _csv(value)
if (
not architectures
or len(architectures) != len(set(architectures))
or not set(architectures) <= ARCHITECTURES
or ("all" in architectures and len(architectures) != 1)
):
raise ValueError(f"{path}: invalid architectures={value}")
return architectures
def _validate_per_arch_kernel(path: Path, value: str, architectures: list[str]) -> None:
per_arch = _csv(value)
per_arch_names = [item.split(":", 1)[0] for item in per_arch]
valid = (
per_arch
and all(ARCH_KERNEL_VERSION.fullmatch(item) for item in per_arch)
and len(per_arch_names) == len(set(per_arch_names))
and set(per_arch_names) == set(architectures)
)
if not valid:
raise ValueError(f"{path}: invalid kernel_min={value}")
def _validate_kernel(path: Path, kernel_min: str, basis: str, architectures: list[str]) -> None:
special_values = {"not-applicable", "unknown"}
if kernel_min not in special_values and not KERNEL_VERSION.fullmatch(kernel_min):
_validate_per_arch_kernel(path, kernel_min, architectures)
if basis not in KERNEL_BASES:
raise ValueError(f"{path}: invalid kernel_min_basis={basis}")
if (kernel_min == "not-applicable") != (basis == "not-applicable"):
raise ValueError(f"{path}: kernel_min and kernel_min_basis disagree")
if (kernel_min == "unknown") != (basis == "unverified"):
raise ValueError(f"{path}: unknown kernel_min must use an unverified basis")
if basis == "repository-baseline" and kernel_min != REPOSITORY_BASELINE:
raise ValueError(
f"{path}: repository-baseline must be {REPOSITORY_BASELINE}, got {kernel_min}"
)
def _validate_choice(path: Path, field: str, value: str, allowed: set[str]) -> None:
if value not in allowed:
raise ValueError(f"{path}: invalid {field}={value}")
def _validate_kernel_config(path: Path, metadata: dict[str, str]) -> None:
value = metadata["kernel_config"]
configs = _csv(value)
if value != "none" and (
not configs
or len(configs) != len(set(configs))
or not all(KERNEL_CONFIG.fullmatch(item) for item in configs)
):
raise ValueError(f"{path}: invalid kernel_config={value}")
if metadata["btf"] == "required" and "CONFIG_DEBUG_INFO_BTF=y" not in configs:
raise ValueError(f"{path}: btf=required needs CONFIG_DEBUG_INFO_BTF=y")
if metadata["kernel_min"] != "not-applicable" and not {
"CONFIG_BPF=y",
"CONFIG_BPF_SYSCALL=y",
} <= set(configs):
raise ValueError(f"{path}: runnable tutorials need CONFIG_BPF=y and CONFIG_BPF_SYSCALL=y")
def _validate_docs_only(path: Path, metadata: dict[str, str]) -> None:
is_not_applicable = metadata["kernel_min"] == "not-applicable"
if is_not_applicable != (metadata["test_status"] == "docs-only"):
raise ValueError(f"{path}: docs-only status and not-applicable kernel must agree")
defaults = {
"kernel_min_basis": "not-applicable",
"architectures": "all",
"btf": "not-required",
"kernel_config": "none",
"hardware": "none",
"root": "not-required",
"test_status": "docs-only",
}
if is_not_applicable and any(metadata[field] != value for field, value in defaults.items()):
raise ValueError(f"{path}: not-applicable tutorials must use docs-only defaults")
def validate_metadata(path: Path, metadata: dict[str, str]) -> None:
missing = [field for field in REQUIRED_FIELDS if not metadata.get(field)]
if missing:
raise ValueError(f"{path}: missing required fields: {', '.join(missing)}")
architectures = _validate_architectures(path, metadata["architectures"])
_validate_kernel(
path, metadata["kernel_min"], metadata["kernel_min_basis"], architectures
)
_validate_choice(path, "btf", metadata["btf"], BTF_VALUES)
_validate_choice(path, "root", metadata["root"], ROOT_VALUES)
_validate_choice(path, "test_status", metadata["test_status"], TEST_STATUSES)
_validate_choice(path, "hardware", metadata["hardware"], HARDWARE_VALUES)
_validate_kernel_config(path, metadata)
_validate_docs_only(path, metadata)
def _title(path: Path) -> str:
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith("#"):
return line.lstrip("#").strip()
raise ValueError(f"{path}: missing Markdown title")
def collect_tutorials(src_dir: Path) -> list[dict[str, object]]:
tutorials = []
for config_path in sorted(
src_dir.rglob(".config"), key=lambda path: sort_key(path.relative_to(src_dir))
):
metadata = parse_config(config_path)
validate_metadata(config_path, metadata)
lesson_dir = config_path.parent
english = lesson_dir / "README.md"
chinese = lesson_dir / "README.zh.md"
if not english.exists() or not chinese.exists():
raise ValueError(f"{lesson_dir}: both English and Chinese READMEs are required")
tutorials.append(
{
"relative": lesson_dir.relative_to(src_dir),
"title_en": _title(english),
"title_zh": _title(chinese),
"metadata": metadata,
}
)
return tutorials
DISPLAY = {
"required": "Required",
"optional": "Optional",
"not-required": "Not required",
"not-applicable": "N/A",
"ci-runtime": "CI runtime",
"ci-build": "CI build",
"not-in-ci": "Not in CI",
"docs-only": "Docs only",
"none": "None",
"all": "All",
"documented": "Tutorial docs",
"required-feature": "Required feature",
"repository-baseline": "Repository baseline",
"unverified": "Unverified",
"unknown": "Unknown",
"android-device-or-emulator": "Android device or emulator",
"block-device": "Block device",
"drm-gpu": "DRM GPU",
"gpu": "GPU",
"intel-npu": "Intel NPU",
"network-interface": "Network interface",
"nvidia-cuda-gpu": "NVIDIA CUDA GPU",
}
DISPLAY_ZH = {
"required": "需要",
"optional": "可选",
"not-required": "不需要",
"not-applicable": "不适用",
"ci-runtime": "CI 运行测试",
"ci-build": "CI 构建测试",
"not-in-ci": "未纳入 CI",
"docs-only": "仅文档",
"none": "无",
"all": "全部",
"documented": "教程文档",
"required-feature": "必需特性",
"repository-baseline": "仓库基线",
"unverified": "尚未验证",
"unknown": "未知",
"android-device-or-emulator": "Android 设备或模拟器",
"block-device": "块设备",
"drm-gpu": "DRM GPU",
"gpu": "GPU",
"intel-npu": "Intel NPU",
"network-interface": "网络接口",
"nvidia-cuda-gpu": "NVIDIA CUDA GPU",
}
def _display(value: str, chinese: bool = False) -> str:
mapping = DISPLAY_ZH if chinese else DISPLAY
if "," in value:
return ", ".join(_display(item, chinese) for item in _csv(value))
return mapping.get(value, value.replace("-", " "))
def render(tutorials: list[dict[str, object]], chinese: bool = False) -> str:
if chinese:
lines = [
"# 教程兼容性矩阵",
"",
"<!-- Generated by scripts/generate_compatibility.py; do not edit directly. -->",
"",
"本表由各教程的 `.config` 元数据生成。`最低内核` 表示教程按当前写法声明支持的最低版本;`依据` 标明该值来自教程文档、必需特性首次出现的版本,还是仓库声明的基线。`未纳入 CI` 不表示已做过手工验证。内核发行版可能禁用所列配置,因此版本号本身并不能保证兼容。",
"",
"| 教程 | 最低内核 | 依据 | 架构 | BTF | 核心内核配置 | 硬件 | Root | 测试状态 |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
else:
lines = [
"# Tutorial compatibility matrix",
"",
"<!-- Generated by scripts/generate_compatibility.py; do not edit directly. -->",
"",
"This table is generated from each tutorial's `.config` metadata. `Minimum kernel` means the oldest kernel that the tutorial currently declares support for. `Basis` says whether that value comes from the tutorial documentation, the first version of a required feature, or the repository's declared baseline. `Not in CI` does not imply that a manual test was performed. Distribution kernels may disable a listed option, so a version number alone does not guarantee compatibility.",
"",
"| Tutorial | Minimum kernel | Basis | Architectures | BTF | Core kernel config | Hardware | Root | Test status |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
for tutorial in tutorials:
relative = tutorial["relative"]
metadata = tutorial["metadata"]
assert isinstance(relative, Path)
assert isinstance(metadata, dict)
readme = "README.zh.md" if chinese else "README.md"
title_key = "title_zh" if chinese else "title_en"
title = str(tutorial[title_key]).replace("|", "\\|")
link = f"{relative.as_posix()}/{readme}"
values = [
metadata["kernel_min"],
metadata["kernel_min_basis"],
metadata["architectures"],
metadata["btf"],
metadata["kernel_config"],
metadata["hardware"],
metadata["root"],
metadata["test_status"],
]
displayed = [_display(value, chinese).replace("|", "\\|") for value in values]
lines.append(f"| [{title}]({link}) | {' | '.join(displayed)} |")
lines.extend(["", "## Metadata fields" if not chinese else "## 元数据字段", ""])
if chinese:
lines.extend(
[
"- `kernel_min` 与 `kernel_min_basis`:教程声明的最低内核及其依据;按架构不同时使用 `架构:版本`。",
"- `architectures`:教程当前声明支持的架构。",
"- `btf` 与 `kernel_config`:BTF 要求以及各实现路径共有的核心内核选项;供应商或挂载点特有的条件请参阅对应教程。`y|m` 表示内建或模块均可。",
"- `hardware`:运行教程所需的专用硬件或环境;`none` 表示不需要专用硬件。",
"- `root`:运行教程是否需要 root 权限。",
"- `test_status`:`ci-runtime` 会在 CI 中实际运行,`ci-build` 仅验证构建,`not-in-ci` 表示没有 CI 证据,`docs-only` 表示纯文档。",
"",
"字段定义和允许值由 `scripts/generate_compatibility.py` 校验;修改教程要求时,请更新对应 `.config` 并重新运行生成脚本。",
]
)
else:
lines.extend(
[
"- `kernel_min` and `kernel_min_basis`: the declared minimum kernel and its evidence basis; use `architecture:version` when architectures differ.",
"- `architectures`: architectures the tutorial currently declares support for.",
"- `btf` and `kernel_config`: the BTF requirement and core kernel options common to the implementation paths; see the tutorial for vendor- or hook-specific conditions. `y|m` means built in or loaded as a module.",
"- `hardware`: specialized hardware or environment required to run the tutorial; `none` means no specialized hardware.",
"- `root`: whether running the tutorial requires root privileges.",
"- `test_status`: `ci-runtime` executes in CI, `ci-build` checks only the build, `not-in-ci` means there is no CI evidence, and `docs-only` is documentation only.",
"",
"Field definitions and allowed values are validated by `scripts/generate_compatibility.py`. When a tutorial requirement changes, update its `.config` and rerun the generator.",
]
)
lines.append("")
return "\n".join(lines)
def generate(project_root: Path, check: bool = False) -> None:
tutorials = collect_tutorials(project_root / "src")
outputs = {
project_root / "src" / "compatibility.md": render(tutorials),
project_root / "src" / "compatibility.zh.md": render(tutorials, chinese=True),
}
stale = []
for path, content in outputs.items():
if check:
if not path.exists() or path.read_text(encoding="utf-8") != content:
stale.append(path)
else:
path.write_text(content, encoding="utf-8")
print(f"Generated: {path}")
if stale:
names = ", ".join(str(path.relative_to(project_root)) for path in stale)
raise SystemExit(f"Generated compatibility documentation is stale: {names}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true", help="validate without rewriting files")
args = parser.parse_args()
generate(Path(__file__).resolve().parent.parent, check=args.check)
if __name__ == "__main__":
main()