Spaces:
Runtime error
Runtime error
File size: 7,617 Bytes
4093877 e74628e 4093877 e74628e 4093877 e74628e 4093877 2afa5a5 4093877 2afa5a5 4093877 2afa5a5 e74628e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | """
Tests du Dockerfile et du contexte de build.
Ils ne construisent pas l'image — trop lent pour une suite — mais verifient les
proprietes dont l'absence se paie en production :
- sans poppler, GET /laws/{id}/page/{n} repond 500 alors qu'il fonctionne en
developpement, ou le binaire est installe sur la machine ;
- sans .dockerignore, le COPY . . embarque .env, donc les cles d'API, dans une
couche de l'image ;
- sans migration au demarrage, le conteneur sert un schema incomplet : les
colonnes search_vector, les index GIN et les declencheurs n'existent que dans
alembic, jamais dans Base.metadata ;
- sans versions figees, l'image prend les dernieres versions du jour de sa
construction, que la suite n'a jamais vues.
"""
import pathlib
import re
from importlib import metadata
import pytest
ROOT = pathlib.Path(__file__).resolve().parents[2]
DOCKERFILE = (ROOT / "Dockerfile").read_text(encoding="utf-8")
DOCKERIGNORE_PATH = ROOT / ".dockerignore"
REQUIREMENTS = (ROOT / "requirements.txt").read_text(encoding="utf-8")
CONSTRAINTS_PATH = ROOT / "constraints.txt"
COPIE_DU_CODE = "COPY --chown=jurix:jurix . ."
def _nom(nom: str) -> str:
"""Nom de paquet normalise (PEP 503) : SQLAlchemy, sqlalchemy et SQL_Alchemy se valent."""
return re.sub(r"[-_.]+", "-", nom).lower()
def _requirements():
from packaging.requirements import Requirement
lignes = (ligne.split("#")[0].strip() for ligne in REQUIREMENTS.splitlines())
return [Requirement(ligne) for ligne in lignes if ligne]
def _contraintes() -> dict:
lignes = CONSTRAINTS_PATH.read_text(encoding="utf-8").splitlines()
paires = (ligne.split("==", 1) for ligne in lignes if ligne and not ligne.startswith("#"))
return {_nom(nom): version for nom, version in paires}
class TestSystemDependencies:
@pytest.mark.parametrize("package", [
"poppler-utils", # pdftoppm, dont depend pdf2image
"tesseract-ocr",
"tesseract-ocr-fra",
"tesseract-ocr-eng",
])
def test_package_is_installed(self, package):
assert package in DOCKERFILE, (
f"{package} absent du Dockerfile : la fonctionnalite qui en depend "
f"marchera en developpement et echouera en production."
)
def test_tesseract_path_is_set_for_linux(self):
assert "TESSERACT_PATH=/usr/bin/tesseract" in DOCKERFILE
class TestBuildContext:
def test_dockerignore_exists(self):
assert DOCKERIGNORE_PATH.is_file(), (
"Sans .dockerignore, COPY . . embarque .env dans l'image."
)
@pytest.mark.parametrize("pattern", [".env", ".git", ".venv", "data/", "tests/", "deploiement/"])
def test_excludes_what_must_not_ship(self, pattern):
content = DOCKERIGNORE_PATH.read_text(encoding="utf-8")
lines = {line.strip() for line in content.splitlines()}
assert pattern in lines, f"{pattern} devrait etre exclu du contexte de build"
def test_dockerignore_est_versionne(self):
"""
Il etait ignore par le .gitignore (*.dockerignore) : absent de tout
checkout et de `git archive`, il ne protegeait que la machine ou il
avait ete ecrit. Le build sur la VM aurait embarque le .env de
production.
"""
import subprocess
try:
resultat = subprocess.run(
["git", "check-ignore", "-q", ".dockerignore"], cwd=ROOT, capture_output=True
)
except FileNotFoundError:
pytest.skip("git absent")
if resultat.returncode == 128:
pytest.skip("hors d'un depot git")
assert resultat.returncode == 1, ".dockerignore est ignore par git"
def test_env_example_stays_included(self):
"""Le modele de configuration, lui, doit rester : il documente les cles."""
content = DOCKERIGNORE_PATH.read_text(encoding="utf-8")
assert "!.env.example" in content
class TestStartup:
def test_migrations_run_before_the_server(self):
assert "alembic upgrade head" in DOCKERFILE
upgrade_at = DOCKERFILE.index("alembic upgrade head")
uvicorn_at = DOCKERFILE.index("uvicorn app.main:app", upgrade_at)
assert upgrade_at < uvicorn_at
def test_runs_as_a_non_root_user(self):
assert "USER jurix" in DOCKERFILE
def test_pas_de_journal_d_acces(self):
"""Il ecrivait le jeton d'administration du WebSocket (?token=...) en clair."""
assert "--no-access-log" in DOCKERFILE
class TestCouches:
def test_le_modele_fasttext_precede_le_code(self):
"""
Telecharge apres la copie du code, le modele (126 Mo) l'etait a chaque
mise a jour ; et un echec donnait, sans erreur, une image sans
detection de langue.
"""
assert DOCKERFILE.index("lid.176.bin") < DOCKERFILE.index(COPIE_DU_CODE)
assert "skip language detection" not in DOCKERFILE
def test_pas_de_chown_recursif_apres_le_code(self):
"""Il recopiait tout /app, modele compris, dans une couche de plus."""
assert "chown -R" not in DOCKERFILE.split(COPIE_DU_CODE, 1)[1]
class TestVersionsFigees:
"""
requirements.txt donne des fourchettes. Sans contraintes, l'image prenait
les dernieres versions du jour de sa construction : SQLAlchemy 2.1, sorti
entre deux, n'installait plus greenlet, et l'API plantait au demarrage
alors que tout passait en developpement, en 2.0.52.
"""
# Outils d'installation, et non code de l'application : le venv peut avoir
# les siens (conda), que scripts/figer_versions.sh laisse libres.
OUTILS = {"pip", "setuptools", "wheel", "packaging"}
def test_l_image_installe_les_versions_figees(self):
assert "COPY requirements.txt constraints.txt ./" in DOCKERFILE
assert "-r requirements.txt -c constraints.txt" in DOCKERFILE
def test_chaque_dependance_est_figee(self):
contraintes = _contraintes()
absentes = [r.name for r in _requirements() if _nom(r.name) not in contraintes]
assert not absentes, (
f"Absentes de constraints.txt : {absentes}. Lancer scripts/figer_versions.sh."
)
def test_les_versions_figees_respectent_requirements(self):
contraintes = _contraintes()
hors = [
f"{r.name}=={contraintes[_nom(r.name)]} hors de {r.specifier}"
for r in _requirements()
if _nom(r.name) in contraintes and contraintes[_nom(r.name)] not in r.specifier
]
assert not hors, f"{hors}. Lancer scripts/figer_versions.sh."
def test_greenlet_est_installe(self):
"""sqlalchemy.ext.asyncio en a besoin, et SQLAlchemy 2.1 ne l'installe plus."""
sqlalchemy = next(r for r in _requirements() if _nom(r.name) == "sqlalchemy")
assert "asyncio" in sqlalchemy.extras
assert "greenlet" in _contraintes()
def test_la_suite_tourne_sur_les_versions_de_l_image(self):
"""Ce que les tests valident doit etre ce que l'image installe."""
ecarts = []
for nom, version in _contraintes().items():
if nom in self.OUTILS:
continue
try:
installee = metadata.version(nom)
except metadata.PackageNotFoundError:
continue
if installee != version:
ecarts.append(f"{nom} : venv {installee}, image {version}")
assert not ecarts, (
f"{ecarts}. Aligner le venv (pip install -r requirements.txt -c constraints.txt) "
f"ou, apres une mise a jour voulue, lancer scripts/figer_versions.sh."
)
|