#!/usr/bin/env python3 """Fail when a change set mixes Terraform with deployable application files. APP_PATHS is newline-separated. A trailing slash is a directory prefix. Any other entry is an exact file. Paths that are not listed are neutral, so workflows, docs, and tests may travel with either side. An empty APP_PATHS skips the check. TERRAFORM_DIR is the Terraform working directory (default terraform). A path is Terraform when it equals that directory or sits under it. """ from __future__ import annotations import argparse import os import sys def parse_app_rules(raw: str) -> tuple[frozenset[str], frozenset[str]]: prefixes: set[str] = set() exact: set[str] = set() for line in raw.splitlines(): item = line.strip().replace("\\", "/") if not item or item.startswith("#"): continue if item.endswith("/"): prefixes.add(item) else: exact.add(item) return frozenset(prefixes), frozenset(exact) def terraform_prefix(terraform_dir: str) -> str: prefix = terraform_dir.replace("\\", "/").strip().strip("/") return prefix or "terraform" def is_terraform_path(path: str, terraform_dir: str = "terraform") -> bool: normalized = path.replace("\\", "/") prefix = terraform_prefix(terraform_dir) return normalized == prefix or normalized.startswith(f"{prefix}/") def is_app_path(path: str, prefixes: frozenset[str], exact: frozenset[str]) -> bool: normalized = path.replace("\\", "/") if normalized in exact: return True for prefix in prefixes: if normalized.startswith(prefix) or f"{normalized}/" == prefix: return True return False def isolation_violation( paths: list[str], app_paths: str, terraform_dir: str = "terraform", ) -> tuple[list[str], list[str]] | None: prefixes, exact = parse_app_rules(app_paths) if not prefixes and not exact: return None terraform_files = sorted( {path for path in paths if is_terraform_path(path, terraform_dir)} ) app_files = sorted({path for path in paths if is_app_path(path, prefixes, exact)}) if terraform_files and app_files: return terraform_files, app_files return None def first_isolation_violation( file_sets: list[list[str]], app_paths: str, terraform_dir: str = "terraform", ) -> tuple[list[str], list[str]] | None: for paths in file_sets: violation = isolation_violation(paths, app_paths, terraform_dir) if violation is not None: return violation return None def main() -> int: parser = argparse.ArgumentParser() parser.add_argument( "paths", nargs="*", help="Changed paths. Omit and pass newline-separated paths on stdin.", ) args = parser.parse_args() paths = list(args.paths) if not paths and not sys.stdin.isatty(): paths = [line.strip() for line in sys.stdin if line.strip()] terraform_dir = terraform_prefix(os.environ.get("TERRAFORM_DIR", "terraform")) violation = isolation_violation( paths, os.environ.get("APP_PATHS", ""), terraform_dir ) if violation is None: print("PASS: application and Terraform changes are isolated") return 0 terraform_files, app_files = violation print( f"FAIL: do not mix deployable application files with {terraform_dir}/", file=sys.stderr, ) print("terraform:", file=sys.stderr) for path in terraform_files: print(f" {path}", file=sys.stderr) print("application:", file=sys.stderr) for path in app_files: print(f" {path}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())