Files
bpf-developer-tutorial/scripts/generate_compatibility.py
云微 37375ff87e 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>
2026-07-14 05:00:48 -07:00

364 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()