feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user