133 lines
5.6 KiB
Python
133 lines
5.6 KiB
Python
"""Check the environment reference against source without importing application settings.
|
|
|
|
Only tracked-source locations are inspected. Deployment .env files, process
|
|
environment values and runtime data are never opened or evaluated.
|
|
"""
|
|
|
|
import ast
|
|
from dataclasses import dataclass
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Setting:
|
|
names: tuple[str, ...]
|
|
default: str
|
|
|
|
|
|
def settings_inventory(source: str) -> list[Setting]:
|
|
tree = ast.parse(source.lstrip("\ufeff"))
|
|
settings = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Settings")
|
|
result = []
|
|
for node in settings.body:
|
|
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
|
|
continue
|
|
name = node.target.id
|
|
names = (name.upper(),)
|
|
default = node.value
|
|
if isinstance(default, ast.Call):
|
|
arguments = {keyword.arg: keyword.value for keyword in default.keywords}
|
|
alias = arguments.get("validation_alias")
|
|
if isinstance(alias, ast.Constant):
|
|
names = (alias.value,)
|
|
elif isinstance(alias, ast.Call):
|
|
names = tuple(ast.literal_eval(argument) for argument in alias.args)
|
|
default = arguments.get("default")
|
|
if isinstance(default, ast.Name):
|
|
value = "@" + default.id
|
|
else:
|
|
value = json.dumps(ast.literal_eval(default), ensure_ascii=True)
|
|
result.append(Setting(names, value))
|
|
return result
|
|
|
|
|
|
def python_environment_names(source: str) -> set[str]:
|
|
names = set()
|
|
for node in ast.walk(ast.parse(source.lstrip("\ufeff"))):
|
|
argument = None
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.args:
|
|
receiver = ast.unparse(node.func.value)
|
|
if (node.func.attr == "getenv" and receiver == "os") or (
|
|
node.func.attr == "get" and receiver in {"os.environ", "environ", "environment", "prepared"}
|
|
):
|
|
argument = node.args[0]
|
|
elif isinstance(node, ast.Subscript) and ast.unparse(node.value) in {
|
|
"os.environ", "environ", "environment", "prepared"
|
|
}:
|
|
argument = node.slice
|
|
if isinstance(argument, ast.Constant) and isinstance(argument.value, str) and ENV_NAME.fullmatch(argument.value):
|
|
names.add(argument.value)
|
|
return names
|
|
|
|
|
|
def runtime_environment_names(root: Path) -> set[str]:
|
|
names = set()
|
|
sources = [*root.glob("backend/app/**/*.py"), *root.glob("scripts/*.py")]
|
|
for path in sources:
|
|
names.update(python_environment_names(path.read_text(encoding="utf-8")))
|
|
|
|
javascript = [*root.glob("frontend/app/**/*.ts"), *root.glob("frontend/app/**/*.tsx"),
|
|
*root.glob("scripts/*.cjs"), root / "frontend/proxy.ts", root / "frontend/next.config.js"]
|
|
for path in javascript:
|
|
if ".test." not in path.name:
|
|
names.update(re.findall(r"process\.env\.([A-Z][A-Z0-9_]*)", path.read_text(encoding="utf-8")))
|
|
|
|
deployment = [*root.glob("*compose*.yml"), *root.glob("scripts/*.sh"), *root.glob("scripts/*.ps1"),
|
|
*root.glob(".gitea/workflows/*.yml")]
|
|
for path in deployment:
|
|
source = path.read_text(encoding="utf-8")
|
|
names.update(re.findall(r"\$\{([A-Z][A-Z0-9_]*)", source))
|
|
names.update(re.findall(r"\$env:([A-Z][A-Z0-9_]*)", source))
|
|
names.update(re.findall(r"secrets\.([A-Z][A-Z0-9_]*)", source))
|
|
# These are shell syntax/builtins, not Magent configuration options.
|
|
names.difference_update({"BASH_SOURCE", "HOME", "RANDOM"})
|
|
|
|
dockerfile = (root / "Dockerfile").read_text(encoding="utf-8").replace("\\\n", " ")
|
|
for line in dockerfile.splitlines():
|
|
if line.startswith(("ENV ", "ARG ")):
|
|
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
|
|
supervisor = (root / "docker/supervisord.conf").read_text(encoding="utf-8")
|
|
for line in supervisor.splitlines():
|
|
if line.startswith("environment="):
|
|
names.update(re.findall(r"\b([A-Z][A-Z0-9_]*)=", line))
|
|
return names
|
|
|
|
|
|
def check_documentation(root: Path = ROOT) -> tuple[list[str], int]:
|
|
document = (root / "docs/ENVIRONMENT.md").read_text(encoding="utf-8")
|
|
documented = set(re.findall(r"`([A-Z][A-Z0-9_]*)`", document))
|
|
settings = settings_inventory((root / "backend/app/config.py").read_text(encoding="utf-8"))
|
|
required = runtime_environment_names(root) | {name for setting in settings for name in setting.names}
|
|
errors = [f"Undocumented environment variable: {name}" for name in sorted(required - documented)]
|
|
defaults = {}
|
|
for line in document.splitlines():
|
|
cells = line.split("|")
|
|
if len(cells) >= 4 and cells[1].strip().startswith("`"):
|
|
for name in re.findall(r"`([A-Z][A-Z0-9_]*)`", cells[1]):
|
|
defaults[name] = cells[2].strip().strip("`")
|
|
for setting in settings:
|
|
for name in setting.names:
|
|
if name in documented and defaults.get(name) != setting.default:
|
|
errors.append(f"Stale source default for {name}: expected {setting.default!r}, documented {defaults.get(name)!r}")
|
|
return errors, len(required)
|
|
|
|
|
|
def main() -> int:
|
|
errors, count = check_documentation()
|
|
if errors:
|
|
print("\n".join(errors), file=sys.stderr)
|
|
return 1
|
|
print(f"Environment documentation covers {count} source-declared variables; Settings defaults match.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|