Files
homelab-infra/scripts/check_env_sync.py
2026-03-23 04:29:25 +00:00

198 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""Sync .env.example: keep structure/comments, merge in keys from .env."""
from __future__ import annotations
import argparse
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKIP_DIRS = {".git", "site", "__pycache__"}
class EnvParseError(Exception):
pass
def discover_pairs(repo_root: Path) -> list[tuple[Path, Path]]:
pairs: list[tuple[Path, Path]] = []
for example_path in sorted(repo_root.rglob(".env.example")):
rel_parts = set(example_path.relative_to(repo_root).parts)
if rel_parts & SKIP_DIRS:
continue
env_path = example_path.with_name(".env")
pairs.append((example_path, env_path))
return pairs
def parse_env_key_values(path: Path) -> tuple[list[str], dict[str, str]]:
keys: list[str] = []
values: dict[str, str] = {}
seen: set[str] = set()
for idx, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise EnvParseError(f"{path}: line {idx} is not KEY=VALUE format")
key, value = line.split("=", 1)
key = key.strip()
if not key:
raise EnvParseError(f"{path}: line {idx} has empty key")
if key in seen:
raise EnvParseError(f"{path}: duplicate key '{key}'")
seen.add(key)
keys.append(key)
values[key] = value
return keys, values
def parse_env_keys(path: Path) -> list[str]:
keys, _ = parse_env_key_values(path)
return keys
def merge_env_into_example(env_path: Path, example_values: dict[str, str]) -> str:
"""
Build .env.example from .env structure.
.env defines: keys, order, comments.
.env.example provides: default values (or placeholders for new keys).
"""
out_lines: list[str] = []
# Read .env to get structure/order/comments, fill in values from .env.example
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in raw_line:
out_lines.append(raw_line)
continue
key, value_from_env = raw_line.split("=", 1)
key = key.strip()
# Use default from .env.example if it exists, otherwise use placeholder
if key in example_values:
out_lines.append(f"{key}={example_values[key]}")
else:
# New key not in template: use placeholder
out_lines.append(f"{key}=__CHANGEME__")
return "\n".join(out_lines) + "\n"
def compare_env(example_path: Path, env_path: Path) -> list[str]:
"""Check if .env.example matches .env schema."""
errors: list[str] = []
if not env_path.exists():
rel_env = env_path.relative_to(REPO_ROOT)
errors.append(f"Missing .env (schema): {rel_env}")
return errors
try:
env_keys, _ = parse_env_key_values(env_path)
example_keys = parse_env_keys(example_path) if example_path.exists() else []
except EnvParseError as exc:
errors.append(str(exc))
return errors
example_set = set(example_keys)
env_set = set(env_keys)
missing_in_example = [k for k in env_keys if k not in example_set]
if missing_in_example:
rel_example = example_path.relative_to(REPO_ROOT)
errors.append(f"{rel_example}: missing keys from .env schema: {', '.join(missing_in_example)}")
return errors
def fix_env_sync(env_path: Path, example_path: Path) -> tuple[bool, str | None]:
"""Update .env.example: .env defines schema, .env.example provides defaults."""
rel_env = env_path.relative_to(REPO_ROOT)
rel_example = example_path.relative_to(REPO_ROOT)
if not env_path.exists():
return False, f"Missing .env file (schema source): {rel_env}"
try:
_, example_values = parse_env_key_values(example_path) if example_path.exists() else ([], {})
desired = merge_env_into_example(env_path, example_values)
except EnvParseError as exc:
return False, str(exc)
current = example_path.read_text(encoding="utf-8") if example_path.exists() else None
if current == desired:
return False, None
example_path.write_text(desired, encoding="utf-8")
if current is None:
return True, f"Created {rel_example} from {rel_env} schema"
return True, f"Synced {rel_example} to match {rel_env}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--fix",
action="store_true",
help="Update .env.example from .env schema, keeping default values",
)
args = parser.parse_args()
pairs = discover_pairs(REPO_ROOT)
if not pairs:
print("No .env.example files found.")
return 0
if args.fix:
changed: list[str] = []
errors: list[str] = []
for example_path, env_path in pairs:
did_change, message = fix_env_sync(env_path, example_path)
if message:
if did_change:
changed.append(message)
else:
errors.append(message)
if changed:
print("Synced .env.example from .env schema:\n")
for item in changed:
print(f"- {item}")
print("\nRe-stage changed .env.example files and commit again.")
return 1
if errors:
print("Environment file sync failed:\n")
for err in errors:
print(f"- {err}")
return 1
print(f"Environment files in sync ({len(pairs)} checked).")
return 0
all_errors: list[str] = []
for example_path, env_path in pairs:
all_errors.extend(compare_env(example_path, env_path))
if all_errors:
print("Environment file validation failed:\n")
for err in all_errors:
print(f"- {err}")
return 1
print(f"Environment files look good ({len(pairs)} checked).")
return 0
if __name__ == "__main__":
raise SystemExit(main())