Spaces:
Sleeping
Sleeping
Claude
feat(sprint-A16): build Docker reproductible (digest + lock file) — clôture M-2
df7146b unverified | """Tests Sprint A16 — reproductibilité du build Docker. | |
| Items couverts : | |
| - **digest-pinning** : la base image ``python:3.11.13-slim`` est | |
| référencée par ``@sha256:...`` (et pas seulement par tag), pour | |
| geler l'image binaire bit-à-bit entre deux ``docker build``. | |
| - **lock file** : ``requirements-docker.lock`` existe, est aligné sur | |
| les extras consommés par le Dockerfile (``[web,llm]``), et couvre | |
| toutes les top-level dependencies déclarées dans ``pyproject.toml``. | |
| - **install path** : le Dockerfile consomme le lock avec ``--no-deps`` | |
| (pas de re-résolution dynamique de l'arbre transitif), et installe | |
| Picarones en éditable séparément (toujours en ``--no-deps`` puisque | |
| le lock contient déjà ses dépendances). | |
| Ces tests sont cheap (lecture de fichiers, regex) — ils ne lancent | |
| pas ``uv pip compile`` ni ``docker build``. La validation drift-free | |
| stricte du lock contre ``uv pip compile`` reste à arbitrer (nécessite | |
| ``uv`` en CI ; non bloquant tant que le lock est régénéré à la main | |
| quand pyproject change, et que le test #7 attrape les oublis grossiers). | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from pathlib import Path | |
| REPO_ROOT = Path(__file__).resolve().parents[2] | |
| DOCKERFILE = REPO_ROOT / "Dockerfile" | |
| LOCK = REPO_ROOT / "requirements-docker.lock" | |
| PYPROJECT = REPO_ROOT / "pyproject.toml" | |
| DIGEST_PATTERN = re.compile( | |
| r"python:3\.\d+(\.\d+)?-slim@sha256:[a-f0-9]{64}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Digest pinning | |
| # --------------------------------------------------------------------------- | |
| def _read_dockerfile() -> str: | |
| return DOCKERFILE.read_text(encoding="utf-8") | |
| def test_dockerfile_pins_python_by_digest() -> None: | |
| """L'``ARG PYTHON_BASE_IMAGE`` doit pointer une image avec digest | |
| ``@sha256:...`` — sinon le build n'est pas reproductible.""" | |
| text = _read_dockerfile() | |
| args = re.findall( | |
| r"^ARG PYTHON_BASE_IMAGE=(\S+)", | |
| text, | |
| re.MULTILINE, | |
| ) | |
| assert args, "Aucun ARG PYTHON_BASE_IMAGE trouvé dans le Dockerfile" | |
| for value in args: | |
| assert DIGEST_PATTERN.match(value), ( | |
| f"ARG PYTHON_BASE_IMAGE={value!r} n'est pas pinné par digest. " | |
| "Forme attendue : ``python:3.11.13-slim@sha256:<64hex>``." | |
| ) | |
| def test_runtime_and_builder_share_same_digest() -> None: | |
| """Les deux ``ARG PYTHON_BASE_IMAGE`` (builder + runtime) doivent | |
| avoir le même digest, sinon les couches OS divergent.""" | |
| text = _read_dockerfile() | |
| args = re.findall( | |
| r"^ARG PYTHON_BASE_IMAGE=(\S+)", | |
| text, | |
| re.MULTILINE, | |
| ) | |
| assert len(args) == 2, ( | |
| f"Attendu 2 ARG PYTHON_BASE_IMAGE (builder + runtime), trouvé {len(args)}" | |
| ) | |
| assert args[0] == args[1], ( | |
| f"Builder ({args[0]}) et runtime ({args[1]}) doivent référencer " | |
| "exactement le même digest — sinon la couche de base diverge." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Lock file | |
| # --------------------------------------------------------------------------- | |
| def test_lock_file_exists() -> None: | |
| assert LOCK.exists(), ( | |
| f"{LOCK.name} doit exister (Sprint A16). " | |
| "Le générer via : ``uv pip compile pyproject.toml " | |
| "--extra web --extra llm -o requirements-docker.lock``." | |
| ) | |
| def test_lock_file_header_mentions_correct_extras() -> None: | |
| """Le commentaire d'en-tête de ``uv pip compile`` doit mentionner | |
| les extras ``web`` et ``llm`` — sinon le lock a été généré pour | |
| autre chose et ne reflète pas ce que le Dockerfile installe.""" | |
| head = LOCK.read_text(encoding="utf-8").splitlines()[:5] | |
| header = "\n".join(head) | |
| assert "--extra web" in header, ( | |
| f"Lock header doit mentionner ``--extra web`` ; trouvé : {header!r}" | |
| ) | |
| assert "--extra llm" in header, ( | |
| f"Lock header doit mentionner ``--extra llm`` ; trouvé : {header!r}" | |
| ) | |
| def test_lock_file_nonempty() -> None: | |
| """Au moins ~50 lignes (deps web+llm = fastapi, anthropic, openai, | |
| mistralai et leurs transitives — typiquement ~140 lignes).""" | |
| lines = LOCK.read_text(encoding="utf-8").splitlines() | |
| n_pkg_lines = sum(1 for ln in lines if re.match(r"^[a-z0-9].*==", ln)) | |
| assert n_pkg_lines >= 30, ( | |
| f"Lock file ne contient que {n_pkg_lines} packages — " | |
| "anormal pour [web,llm]. Re-générer le lock." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Couverture top-level pyproject ↔ lock | |
| # --------------------------------------------------------------------------- | |
| def _normalize(name: str) -> str: | |
| """PEP 503 normalization : lowercase, [-_.] → -.""" | |
| return re.sub(r"[-_.]+", "-", name).lower() | |
| def _extract_pyproject_top_level_deps() -> set[str]: | |
| """Renvoie les noms (normalisés) des deps top-level installées | |
| par le Dockerfile : ``[project] dependencies`` + extras ``web`` + ``llm``.""" | |
| text = PYPROJECT.read_text(encoding="utf-8") | |
| def _names_in_block(block: str) -> set[str]: | |
| names: set[str] = set() | |
| for line in block.splitlines(): | |
| line = line.strip().strip(",").strip('"').strip("'") | |
| if not line or line.startswith("#"): | |
| continue | |
| m = re.match(r"^([A-Za-z0-9_.\-]+)", line) | |
| if m: | |
| names.add(_normalize(m.group(1))) | |
| return names | |
| deps_block = re.search(r"^dependencies\s*=\s*\[(.*?)\]", text, re.DOTALL | re.MULTILINE) | |
| web_block = re.search(r"^web\s*=\s*\[(.*?)\]", text, re.DOTALL | re.MULTILINE) | |
| llm_block = re.search(r"^llm\s*=\s*\[(.*?)\]", text, re.DOTALL | re.MULTILINE) | |
| assert deps_block, "[project] dependencies introuvable dans pyproject.toml" | |
| assert web_block, "extra ``web`` introuvable dans pyproject.toml" | |
| assert llm_block, "extra ``llm`` introuvable dans pyproject.toml" | |
| return ( | |
| _names_in_block(deps_block.group(1)) | |
| | _names_in_block(web_block.group(1)) | |
| | _names_in_block(llm_block.group(1)) | |
| ) | |
| def _extract_lock_pkg_names() -> set[str]: | |
| text = LOCK.read_text(encoding="utf-8") | |
| names: set[str] = set() | |
| for line in text.splitlines(): | |
| m = re.match(r"^([A-Za-z0-9_.\-]+)\s*==", line) | |
| if m: | |
| names.add(_normalize(m.group(1))) | |
| return names | |
| def test_lock_covers_pyproject_top_level_deps() -> None: | |
| """Toutes les top-level deps de pyproject ([project] + extras | |
| web + llm) doivent apparaître dans le lock. Si une dep est ajoutée | |
| à pyproject sans régénérer le lock, ce test attrape l'oubli.""" | |
| pyproject_deps = _extract_pyproject_top_level_deps() | |
| lock_pkgs = _extract_lock_pkg_names() | |
| missing = pyproject_deps - lock_pkgs | |
| # ``picarones`` lui-même n'apparaît pas dans le lock (auto-référence). | |
| missing.discard("picarones") | |
| assert not missing, ( | |
| f"Top-level deps présentes dans pyproject.toml mais absentes du " | |
| f"lock : {sorted(missing)}. " | |
| "Re-générer via : ``uv pip compile pyproject.toml --extra web " | |
| "--extra llm -o requirements-docker.lock``." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Install path dans le Dockerfile | |
| # --------------------------------------------------------------------------- | |
| def test_dockerfile_copies_lock_file() -> None: | |
| text = _read_dockerfile() | |
| assert "COPY requirements-docker.lock" in text, ( | |
| "Dockerfile doit COPY requirements-docker.lock dans le builder, " | |
| "sinon le lock n'est pas disponible au moment du pip install." | |
| ) | |
| def test_dockerfile_installs_from_lock_with_no_deps() -> None: | |
| """Le Dockerfile doit installer ``-r requirements-docker.lock`` avec | |
| ``--no-deps`` (pas de re-résolution) — sinon le lock ne sert à rien.""" | |
| text = _read_dockerfile() | |
| assert "-r requirements-docker.lock" in text, ( | |
| "Dockerfile doit installer depuis le lock : " | |
| "``pip install --no-deps -r requirements-docker.lock``." | |
| ) | |
| # Cherche la ligne d'install et vérifie le ``--no-deps``. | |
| install_lines = [ | |
| ln for ln in text.splitlines() | |
| if "requirements-docker.lock" in ln and "pip install" in ln | |
| ] | |
| assert install_lines, "Ligne d'install du lock introuvable" | |
| for ln in install_lines: | |
| assert "--no-deps" in ln, ( | |
| f"Install du lock sans ``--no-deps`` : {ln!r}. " | |
| "Sans ``--no-deps``, pip re-résoudrait l'arbre et casserait " | |
| "la reproductibilité." | |
| ) | |
| def test_dockerfile_installs_picarones_no_deps() -> None: | |
| """Picarones lui-même doit être installé en ``--no-deps`` car le | |
| lock contient déjà toutes ses dépendances (sinon double install).""" | |
| text = _read_dockerfile() | |
| # On cherche la ligne ``pip install ... -e .`` (avec ou sans extras). | |
| editable_lines = [ | |
| ln for ln in text.splitlines() | |
| if "pip install" in ln and re.search(r"-e\s+\.(\[|$|\s)", ln) | |
| ] | |
| assert editable_lines, "Pas de ``pip install -e .`` dans le Dockerfile" | |
| # Au moins une de ces lignes doit avoir --no-deps. | |
| has_no_deps = any("--no-deps" in ln for ln in editable_lines) | |
| assert has_no_deps, ( | |
| "Aucune ligne ``pip install -e .`` n'utilise ``--no-deps`` ; " | |
| "le lock contient déjà les deps, double install évitable." | |
| ) | |
| def test_dockerfile_does_not_use_implicit_extras() -> None: | |
| """Avec le lock approach, on ne doit PLUS faire ``-e .[web,llm]`` — | |
| les extras sont déjà résolus dans le lock. Si cette ligne réapparaît, | |
| pip va re-résoudre depuis pyproject et le build cesse d'être | |
| reproductible.""" | |
| text = _read_dockerfile() | |
| # Cherche ``-e ".[web,llm]"`` ou variants. | |
| forbidden = re.findall(r'-e\s+["\']?\.\[[^\]]+\]', text) | |
| assert not forbidden, ( | |
| f"Dockerfile contient encore un install avec extras : {forbidden}. " | |
| "Avec le lock, utiliser ``pip install --no-deps -e .`` à la place." | |
| ) | |