""" Guard the deploy-time substitution contract. Every ``@@token@@`` in this repo must be declared in config/environments.yml, and must live somewhere that actually gets rendered. Three things are checked: 1. Tokens inside a render path are declared. Catches typos, which would otherwise ship the literal placeholder. 2. Declared tokens only appear in a render, passthrough or reference path. Catches a placeholder added to a file nobody renders -- exactly the regression that put a suffix token into qt/*.ui while the Qt deploy step was a plain rsync. 3. No pre-migration ``{{ token }}`` form of a deploy-time token survives, in the paths that carry tokens. Catches files missed by the switch to the ``@@token@@`` delimiters. Reference paths are exempt: Ansible and copier own ``{{ }}`` in their own files, and some of their variables share a name with our tokens. Files tracked by git plus new, non-ignored files are scanned, so build and cache directories are skipped for free. """ import sys import git from agebd.subs import ( SubstitutionsConfig, find_legacy_tokens, find_tokens, ) from agebd.utils import get_git_root REPO_ROOT = get_git_root(__file__) def iter_tracked_text_files(repo_root=REPO_ROOT): """ Yield (relative_path, contents) for every UTF-8 decodable file that is tracked, or new and not gitignored. New files are included so that a bad token is reported before it is committed rather than after. """ repo = git.Repo(repo_root) listing = repo.git.ls_files("--cached", "--others", "--exclude-standard") for rel_path in sorted(set(listing.splitlines())): path = repo_root / rel_path if not path.is_file(): continue try: yield rel_path, path.read_text() except (UnicodeDecodeError, OSError): continue def check_declared(config, rel_path, contents): """1. Tokens inside a render path must be declared.""" if not config.is_render_path(rel_path): return [] return [ f"{rel_path}: '@@{token}@@' is not declared under 'tokens' in " f"{config.environments_file.name}" for token in sorted(find_tokens(contents)) if token not in config.tokens ] def carries_tokens(config, rel_path): """ True for paths whose files carry deploy-time tokens as data: they are either rendered, or copied into something that is. """ return config.is_render_path(rel_path) or config.is_passthrough_path(rel_path) def check_location(config, rel_path, contents): """2. Declared tokens must sit in a render, passthrough or reference path.""" if carries_tokens(config, rel_path) or config.is_reference_path(rel_path): return [] return [ f"{rel_path}: '@@{token}@@' is in a path that is never rendered. In " f"{config.environments_file.name}, add the path to 'locations.render' if it " f"should be substituted at deploy time, or to 'locations.references' if it is " f"part of the substitution machinery. Otherwise remove the token." for token in sorted(find_tokens(contents) & set(config.tokens)) ] def check_no_legacy_delimiters(config, rel_path, contents): """ 3. No deploy-time token may still use the creation-time delimiters, in the paths that carry tokens. Elsewhere {{ }} belongs to whichever tool owns the file. """ if not carries_tokens(config, rel_path): return [] return [ f"{rel_path}: '{{{{ {token} }}}}' uses the creation-time delimiters for a " f"deploy-time token. Write it as '@@{token}@@'." for token in sorted(find_legacy_tokens(contents) & set(config.tokens)) ] CHECKS = (check_declared, check_location, check_no_legacy_delimiters) def check_subs(repo_root=REPO_ROOT): """ Raise AssertionError listing every violation, or return silently. """ config = SubstitutionsConfig.load(repo_root / "config") errors = [] for rel_path, contents in iter_tracked_text_files(repo_root): for check in CHECKS: errors.extend(check(config, rel_path, contents)) if errors: raise AssertionError("Deploy-time substitution problems found:\n\n " + "\n ".join(errors)) if __name__ == "__main__": try: check_subs() except AssertionError as e: print(e, file=sys.stderr) sys.exit(1) print("All deploy-time tokens are declared and live in a rendered path.")