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."
        )