"""Static packaging linter for Lambda container images. Every Lambda here ships as a Docker image that copies a *subset* of the repo (``COPY utils/ utils/``, ``COPY backend/ backend/``, ...) and then runs a handler via ``CMD [".handler"]``. If the handler's import graph reaches a top-level package the Dockerfile forgot to ``COPY``, the function dies at cold start with ``Runtime.ImportModuleError: No module named ''`` — but only *inside the image*. In the dev/test tree every package is present, so a plain ``import`` test can't see the gap. This is exactly how ``No module named 'domain'`` reached a deployed address2UPRN. The RIE smoke tests (.github/workflows/_smoke_test_lambda.yml) catch this too, but only by building the full image (minutes) and only for hand-listed services. This test catches the same class of bug in milliseconds, locally, for *every* handler Dockerfile — by statically computing each handler's import-time module graph and asserting every repo file it reaches is copied into the image. Scope: import-time (module-level) imports only — the ones that run at Lambda init, which is what ImportModuleError is about. Imports inside function bodies and under ``if TYPE_CHECKING:`` are deliberately ignored. Third-party / stdlib imports are out of scope (that's requirements.txt's job, covered by the RIE smoke test actually installing and importing). """ from __future__ import annotations import ast import json import re from pathlib import Path from typing import Optional import pytest REPO_ROOT = Path(__file__).resolve().parents[1] # Dockerfiles that are not Lambda handlers (test harness, dev containers). _SKIP_DOCKERFILES = {"Dockerfile.test", "Dockerfile.test.dockerignore"} _SKIP_PARTS = {".git", "node_modules", ".devcontainer"} def _toplevel_names() -> set[str]: """Top-level repo packages/modules — the namespace handler imports resolve against (imports are absolute: ``domain.x``, ``backend.y``).""" names: set[str] = set() for p in REPO_ROOT.iterdir(): if p.name.startswith(".") or p.name == "__pycache__": continue if p.is_dir(): names.add(p.name) elif p.suffix == ".py": names.add(p.stem) return names _TOP = _toplevel_names() def _is_type_checking(test: ast.expr) -> bool: if isinstance(test, ast.Name): return test.id == "TYPE_CHECKING" if isinstance(test, ast.Attribute): return test.attr == "TYPE_CHECKING" return False def _file_package_parts(path: Path) -> list[str]: """The components of ``__package__`` Python assigns when importing ``path``. For a regular module ``a/b/c.py`` and for a package ``a/b/__init__.py`` alike this is the containing directory (``["a", "b"]``) — i.e. the anchor that ``from . import x`` resolves against.""" return list(path.relative_to(REPO_ROOT).parts)[:-1] def _import_time_imports(path: Path) -> list[str]: """Absolute module names imported when ``path`` is imported (i.e. at Lambda init). Descends into module-level if/try/with and class bodies, but not into function bodies (lazy) or ``if TYPE_CHECKING:`` blocks (never executed). Relative imports (``from .x import y``) are resolved to their absolute name against ``path``'s package — the codebase re-exports through package ``__init__.py`` files this way, so dropping them would hide real init-time dependencies (e.g. ``functions/__init__.py`` -> ``from .portfolio_functions import *`` -> ... -> ``infrastructure``).""" try: tree = ast.parse(path.read_text(encoding="utf-8"), str(path)) except (SyntaxError, UnicodeDecodeError): return [] pkg_parts = _file_package_parts(path) out: list[str] = [] def _relative_base(level: int) -> list[str]: # level 1 anchors on the package itself; each extra level climbs one up. keep = len(pkg_parts) - (level - 1) return pkg_parts[:keep] if keep > 0 else [] def visit(stmts: list[ast.stmt]) -> None: for node in stmts: if isinstance(node, ast.Import): out.extend(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom): if not node.level: # absolute import if node.module: out.append(node.module) else: # relative import — resolve against this file's package base = _relative_base(node.level) if node.module: # from .pkg.mod import name out.append(".".join(base + node.module.split("."))) else: # from . import a, b -> base.a, base.b (submodules) out.extend(".".join(base + [alias.name]) for alias in node.names) elif isinstance(node, ast.If): if _is_type_checking(node.test): continue visit(node.body) visit(node.orelse) elif isinstance(node, ast.Try): visit(node.body) visit(node.orelse) visit(node.finalbody) for handler in node.handlers: visit(handler.body) elif isinstance(node, ast.With): visit(node.body) elif isinstance(node, ast.ClassDef): visit(node.body) # FunctionDef / AsyncFunctionDef bodies are intentionally skipped. visit(tree.body) return out def _module_files(module: str) -> list[Path]: """Every repo file executed when ``module`` is imported: the module's own file *plus* each ancestor package's ``__init__.py``. Importing ``a.b.c`` runs ``a/__init__.py``, ``a/b/__init__.py`` and ``a/b/c.py`` (or ``a/b/c/__init__.py``) in turn — so an ``__init__.py`` part way down the path can pull in a whole subtree (and the package it lives in must be COPYed). ``_module_to_file`` resolves only the leaf, which is why the closure used to stop short of those intermediate packages.""" parts = module.split(".") files: list[Path] = [] for depth in range(1, len(parts) + 1): base = REPO_ROOT.joinpath(*parts[:depth]) init = base / "__init__.py" if init.is_file(): files.append(init) if depth == len(parts): # the leaf may be a plain module file leaf = base.with_suffix(".py") if leaf.is_file(): files.append(leaf) return files def _import_closure(start: Path) -> dict[Path, Optional[Path]]: """Repo files reachable from ``start`` via import-time imports, mapped to the first file that imported each (for blame in failure messages).""" reached: dict[Path, Optional[Path]] = {} stack: list[tuple[Path, Optional[Path]]] = [(start, None)] while stack: path, importer = stack.pop() if path in reached: continue reached[path] = importer for module in _import_time_imports(path): if module.split(".")[0] not in _TOP: continue # stdlib / third-party — not our concern here for target in _module_files(module): if target not in reached: stack.append((target, path)) return reached def _norm(path_token: str) -> str: return path_token.lstrip("./").rstrip("/") def _parse_handler_spec(dockerfile_text: str) -> Optional[str]: """The ``.handler`` string from the ``CMD`` line, or None if this isn't a Lambda handler image.""" match = re.search(r"^CMD\s+(\[.*\]|.+)$", dockerfile_text, re.MULTILINE) if not match: return None raw = match.group(1).strip() try: parsed = json.loads(raw) spec = parsed[0] if isinstance(parsed, list) and parsed else raw except json.JSONDecodeError: spec = raw.strip('"') return spec if isinstance(spec, str) and spec.endswith(".handler") else None def _parse_copies(dockerfile_text: str) -> list[tuple[list[str], str]]: """``COPY`` instructions as (sources, dest), dropping ``--flag`` tokens.""" copies: list[tuple[list[str], str]] = [] for match in re.finditer(r"^COPY\s+(.+)$", dockerfile_text, re.MULTILINE): tokens = [t for t in match.group(1).split() if not t.startswith("--")] if len(tokens) < 2: continue *sources, dest = tokens copies.append((sources, dest)) return copies def _resolve_handler_file( spec: str, copies: list[tuple[list[str], str]] ) -> Optional[Path]: """Map a handler spec to its repo source file. Handles both in-place layouts (``backend.foo.handler`` -> ``backend/foo/ handler.py``, present via ``COPY backend/ backend/``) and root-placed handlers (``main.handler`` where a ``COPY /var/task/main.py`` or ``COPY /main.py .`` puts the file at the image root).""" module_path, _func = spec.rsplit(".", 1) direct = REPO_ROOT / (module_path.replace(".", "/") + ".py") if direct.is_file(): return direct # Root-placed module: find the COPY whose destination basename matches. wanted = module_path.split("/")[-1] + ".py" for sources, dest in copies: dest_norm = _norm(dest) dest_is_named_file = Path(dest_norm).name == wanted dest_is_dir = dest_norm in ("", module_path.split("/")[-1]) or dest.endswith("/") for src in sources: src_path = REPO_ROOT / _norm(src) if not src_path.is_file(): continue if dest_is_named_file or (dest_is_dir and src_path.name == wanted): return src_path return None def _is_copied(rel_path: str, copies: list[tuple[list[str], str]]) -> bool: """Whether a repo-relative file path lands in the image via some COPY.""" rel_path = _norm(rel_path) for sources, _dest in copies: for src in sources: src_norm = _norm(src) if src_norm == "" or src_norm == rel_path or rel_path.startswith(src_norm + "/"): return True return False def _package_dir_present(pkg_rel: str, copies: list[tuple[list[str], str]]) -> bool: """Whether the image will contain ``pkg_rel`` as a directory because some COPY brings in a file beneath it. Used to excuse an un-copied package ``__init__.py``: in Python 3 a directory present without its ``__init__.py`` imports fine as a *namespace package*, so the missing ``__init__`` is not a cold-start ``ModuleNotFoundError`` (only a wholly-absent package is).""" pkg_rel = _norm(pkg_rel) for sources, _dest in copies: for src in sources: src_norm = _norm(src) if src_norm == pkg_rel or src_norm.startswith(pkg_rel + "/"): return True return False def _discover_handler_dockerfiles() -> list[Path]: found: list[Path] = [] for path in REPO_ROOT.rglob("*Dockerfile*"): if path.name in _SKIP_DOCKERFILES: continue if any(part in _SKIP_PARTS for part in path.relative_to(REPO_ROOT).parts): continue try: text = path.read_text(encoding="utf-8") except OSError: continue if _parse_handler_spec(text): found.append(path) return sorted(found) _HANDLER_DOCKERFILES = _discover_handler_dockerfiles() def test_handler_dockerfiles_discovered() -> None: """Guard against the discovery silently finding nothing (e.g. a refactor that renames Dockerfiles), which would make every check below vacuous.""" assert _HANDLER_DOCKERFILES, "no Lambda handler Dockerfiles found under repo root" @pytest.mark.parametrize( "dockerfile", _HANDLER_DOCKERFILES, ids=[str(p.relative_to(REPO_ROOT)) for p in _HANDLER_DOCKERFILES], ) def test_lambda_image_copies_full_import_closure(dockerfile: Path) -> None: """Every repo file the handler imports at init must be COPYed into the image.""" text = dockerfile.read_text(encoding="utf-8") spec = _parse_handler_spec(text) assert spec is not None # discovery guaranteed this copies = _parse_copies(text) handler_file = _resolve_handler_file(spec, copies) assert handler_file is not None, ( f"{dockerfile.relative_to(REPO_ROOT)}: could not locate the source file " f"for CMD handler {spec!r}. Update _resolve_handler_file if this is a new " f"handler layout." ) missing: list[str] = [] for reached, importer in _import_closure(handler_file).items(): rel = str(reached.relative_to(REPO_ROOT)) if _is_copied(rel, copies): continue # An un-copied package __init__.py is non-fatal when its directory still # exists in the image (some other file under it is copied): Python falls # back to a namespace package. We still traverse such __init__ files for # their imports above; we just don't demand they be copied. A wholly # absent package (no file under it copied) is a real ModuleNotFoundError. if reached.name == "__init__.py" and _package_dir_present( str(reached.parent.relative_to(REPO_ROOT)), copies ): continue blame = ( str(importer.relative_to(REPO_ROOT)) if importer else "(handler entrypoint)" ) missing.append(f" - {rel}\n imported by {blame}") assert not missing, ( f"{dockerfile.relative_to(REPO_ROOT)} runs `{spec}` but does not COPY " f"{len(missing)} file(s) it imports at init. The Lambda will fail at cold " f"start with Runtime.ImportModuleError. Add the missing top-level " f"package(s) as `COPY / /`:\n" + "\n".join(sorted(missing)) )