Files
greptimedb/scripts/check-enterprise-license.py
dennis zhuang 7a4812c522 chore: check enterprise-gated files are listed in both license configs (#8750)
* chore: check enterprise-gated files are listed in both license configs

A file reachable only through `#[cfg(feature = "enterprise")] mod ...;` is
governed by the GreptimeDB Enterprise License, so it must appear in the
`includes` of licenserc-enterprise.toml and the `excludes` of licenserc.toml.
hawkeye stays silent when it does not: the file keeps its Apache-2.0 header and
passes the default check precisely because it was never excluded from it.

scripts/check-enterprise-license.py walks enterprise-gated `mod` declarations,
resolves them to files (submodules included) and diffs that set against both
configs, also reporting stale entries. It runs in the license job in CI and as
`make check-enterprise-license`.

Documents the split it cannot decide for you — whole enterprise features get
their own file, a gated match arm stays inline — in
.agents/architecture-invariants.md.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* fix: tighten enterprise license checks

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
2026-08-05 02:28:46 +00:00

238 lines
7.7 KiB
Python

# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Keep the two license configs in sync with the enterprise feature gate.
A Rust file reachable only through `#[cfg(feature = "enterprise")] mod ...;` is
governed by the GreptimeDB Enterprise License, so it must be listed in the
`includes` of `licenserc-enterprise.toml` and in the `excludes` of
`licenserc.toml`.
hawkeye cannot catch a miss here: such a file that still carries the Apache-2.0
header passes the default check precisely because it was never excluded from it.
"""
import re
import subprocess
import sys
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
APACHE_CONFIG = "licenserc.toml"
ENTERPRISE_CONFIG = "licenserc-enterprise.toml"
ENTERPRISE_EXCLUDES_START = "# enterprise:start"
ENTERPRISE_EXCLUDES_END = "# enterprise:end"
ATTRIBUTE = re.compile(r"#\[[^\]]*\]")
MOD_DECL = re.compile(
r"^\s*(?:pub\s*(?:\([^)]*\)\s*)?)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;"
)
MODULE_ROOTS = {"mod.rs", "lib.rs", "main.rs"}
CFG_ATTRIBUTE = re.compile(r"#\[\s*cfg\s*\((.*)\)\s*\]")
ENTERPRISE_FEATURE = re.compile(r'feature\s*=\s*"enterprise"')
def cfg_requires_enterprise(attribute):
"""Whether `attribute` prevents the item from compiling without enterprise."""
match = CFG_ATTRIBUTE.fullmatch(attribute)
if not match:
return False
predicate = match.group(1).strip()
if not ENTERPRISE_FEATURE.search(predicate):
return False
if ENTERPRISE_FEATURE.fullmatch(predicate):
return True
all_match = re.fullmatch(r"all\((.*)\)", predicate)
if all_match:
terms = [term.strip() for term in all_match.group(1).split(",")]
return any(ENTERPRISE_FEATURE.fullmatch(term) for term in terms)
return False
def parse_mod_decls(source):
"""Yield `(module_name, enterprise_gated)` for each `mod <name>;` declaration.
Inline `mod <name> { ... }` is skipped: it has no file of its own.
"""
attributes = []
for line in source.splitlines():
rest = line.lstrip()
while rest.startswith("#["):
attribute = ATTRIBUTE.match(rest)
if not attribute:
break
attributes.append(attribute.group())
rest = rest[attribute.end() :].lstrip()
match = MOD_DECL.match(rest)
if match:
gated = any(cfg_requires_enterprise(attr) for attr in attributes)
yield match.group(1), gated
attributes = []
continue
stripped = rest.strip()
if stripped and not stripped.startswith("//"):
attributes = []
def child_module_dir(path):
"""Directory holding the submodules declared by `path`."""
return path.parent if path.name in MODULE_ROOTS else path.parent / path.stem
def resolve_module(parent, name):
directory = child_module_dir(parent)
for candidate in (directory / f"{name}.rs", directory / name / "mod.rs"):
if candidate.is_file():
return candidate
return None
def collect_gated_files(rust_files):
"""Return the files behind every enterprise-gated module, plus unresolved declarations.
Submodules of a gated module are gated too, so the walk keeps descending.
"""
pending = [
(path, name)
for path in rust_files
for name, gated in parse_mod_decls(path.read_text(encoding="utf-8"))
if gated
]
gated_files = set()
unresolved = []
while pending:
parent, name = pending.pop()
target = resolve_module(parent, name)
if target is None:
unresolved.append((parent, name))
continue
if target in gated_files:
continue
gated_files.add(target)
source = target.read_text(encoding="utf-8")
pending.extend((target, child) for child, _ in parse_mod_decls(source))
return gated_files, unresolved
def tracked_rust_files(repo_root):
"""Rust files under version control.
Untracked files are left out on purpose: a scratch copy of a gated module
lying around a working tree would otherwise fail the check. Anything headed
for a PR is tracked by the time CI sees it.
"""
result = subprocess.run(
["git", "-C", str(repo_root), "ls-files", "*.rs"],
capture_output=True,
text=True,
check=True,
)
return [repo_root / line for line in result.stdout.splitlines() if line]
def load_paths(config_path, key):
with open(config_path, "rb") as config:
return set(tomllib.load(config).get(key, []))
def load_marked_paths(config_path, start_marker, end_marker):
"""Load a TOML array fragment delimited by comment markers."""
source = config_path.read_text(encoding="utf-8")
try:
fragment = source.split(start_marker, 1)[1].split(end_marker, 1)[0]
except IndexError as error:
raise ValueError(
f"Missing {start_marker!r} or {end_marker!r} in {config_path}"
) from error
return set(tomllib.loads(f"paths = [{fragment}]")["paths"])
def report(title, paths):
print(f"\n{title}:")
for path in sorted(paths):
print(f' "{path}",')
def main():
rust_files = tracked_rust_files(REPO_ROOT)
gated_files, unresolved = collect_gated_files(rust_files)
gated = {path.relative_to(REPO_ROOT).as_posix() for path in gated_files}
enterprise_includes = load_paths(REPO_ROOT / ENTERPRISE_CONFIG, "includes")
enterprise_excludes = load_marked_paths(
REPO_ROOT / APACHE_CONFIG,
ENTERPRISE_EXCLUDES_START,
ENTERPRISE_EXCLUDES_END,
)
missing_includes = gated - enterprise_includes
missing_excludes = gated - enterprise_excludes
stale_includes = enterprise_includes - gated
stale_excludes = enterprise_excludes - gated
if not (
missing_includes
or missing_excludes
or stale_includes
or stale_excludes
or unresolved
):
print(f"Enterprise license lists are in sync ({len(gated)} gated files).")
return
if missing_includes:
report(
f"Enterprise-gated files missing from `includes` in {ENTERPRISE_CONFIG}",
missing_includes,
)
if missing_excludes:
report(
f"Enterprise files missing from `excludes` in {APACHE_CONFIG} "
"(the Apache-2.0 header must not be applied to them)",
missing_excludes,
)
if stale_includes:
report(
f"Stale `includes` in {ENTERPRISE_CONFIG} (file is gone, or is no "
'longer behind `#[cfg(feature = "enterprise")]`)',
stale_includes,
)
if stale_excludes:
report(
f"Stale enterprise `excludes` in {APACHE_CONFIG} (file is gone, or "
"is no longer enterprise-gated)",
stale_excludes,
)
if unresolved:
print("\nEnterprise-gated modules whose file could not be located:")
for parent, name in sorted(unresolved):
print(f" mod {name}; declared in {parent.relative_to(REPO_ROOT)}")
print(
"\nAn enterprise-gated file carries the enterprise header instead of the "
"Apache-2.0 one. After fixing both lists, run:"
"\n hawkeye format --config licenserc-enterprise.toml"
)
raise SystemExit(1)
if __name__ == "__main__":
sys.exit(main())