ops: un deploiement qui refuse de partir casse, et qui se verifie
OPS-011, en partie. Ce que le workflow garantit maintenant : - rien ne part d un arbre casse. La suite, ruff check et ruff format tournent sur le runner de deploiement avant tout envoi. Une CI verte sur GitHub ne prouve rien ici : le deploiement se declenche a la main, sur ce que la branche contient a cet instant ; - seuls les fichiers nommes partent. La charge est une liste blanche — app/, wsgi.py, requirements.txt — et non l arbre de travail moins neuf exclusions. C est par cette porte que clear_db.py, la suite de tests et les definitions de CI se sont retrouves sur le noeud de production ; - le deploiement est verifie. /health est interroge pendant deux minutes apres l envoi et le job echoue s il ne repond jamais « healthy ». Avant, un arbre a moitie televerse etait un deploiement vert. Ce qui n est pas garanti, et c est ecrit dans le fichier : la bascule n est pas atomique. Le miroir se fait sur place, donc pendant le transfert la production execute un melange de deux versions. En cherchant a fermer ce point, un defaut a part entiere est apparu. Les contrats etaient ranges a os.getcwd()/documents et leur chemin absolu ecrit en base. La racine de stockage suivait donc le repertoire depuis lequel le processus avait ete lance : redemarrer le serveur ailleurs envoie les nouveaux contrats dans un nouvel arbre et rend les anciens illisibles — la base continuant d affirmer qu ils sont la, la panne se manifeste par un 500 au telechargement, pas par quelque chose d actionnable. app/storage.py fixe la racine et DOCUMENTS_ROOT la deplace. Les nouvelles lignes gardent un chemin relatif, les anciennes gardent leur chemin absolu et continuent de resoudre : aucune migration de donnees n est necessaire, donc ce changement n attend pas Alembic. C etait aussi le troisieme pre-requis de la bascule par repertoires de version. Les deux autres sont hors d atteinte d ici — la commande de demarrage Pterodactyl doit pointer sur current/, et les repertoires partages doivent etre installes sur le noeud. Les deux sont decrits dans docs/deployment.md, avec la procedure de retour arriere qui manquait. 511 tests.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""Where uploaded documents live (OPS-011).
|
||||
|
||||
Contract paths were absolute and built from `os.getcwd()`, so the storage
|
||||
root followed whatever directory the process was started from. That is a
|
||||
defect on its own — a server restarted from elsewhere writes new contracts
|
||||
into a new tree and cannot read the old ones, while the database goes on
|
||||
saying they are there — and it is what made a release-directory deployment
|
||||
impossible: every stored path would point inside a release about to be
|
||||
replaced.
|
||||
|
||||
The compatibility case is the one that matters most here. Rows written
|
||||
before this change hold absolute paths, and they have to keep working
|
||||
without a data migration, because the migration tooling does not exist yet
|
||||
(DB-002, blocked).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.storage import CONTRACTS_DIR, document_path, documents_root
|
||||
|
||||
|
||||
class TestDocumentsRoot:
|
||||
def test_the_environment_wins(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'elsewhere'))
|
||||
|
||||
assert documents_root() == str(tmp_path / 'elsewhere')
|
||||
|
||||
def test_a_relative_override_is_made_absolute(self, monkeypatch):
|
||||
monkeypatch.setenv('DOCUMENTS_ROOT', 'docs-here')
|
||||
|
||||
assert os.path.isabs(documents_root())
|
||||
|
||||
def test_the_default_does_not_follow_the_working_directory(self, tmp_path, monkeypatch):
|
||||
"""The whole point. `os.getcwd()` made this move; the package
|
||||
location does not."""
|
||||
monkeypatch.delenv('DOCUMENTS_ROOT', raising=False)
|
||||
before = documents_root()
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
after = documents_root()
|
||||
|
||||
assert before == after
|
||||
|
||||
def test_the_default_sits_beside_the_package(self, monkeypatch):
|
||||
monkeypatch.delenv('DOCUMENTS_ROOT', raising=False)
|
||||
|
||||
# From the module file, not from `app.__file__`: `app` has no
|
||||
# __init__.py, so it is a namespace package and __file__ is None.
|
||||
from app import storage
|
||||
|
||||
package_dir = os.path.dirname(os.path.abspath(storage.__file__))
|
||||
expected = os.path.join(os.path.dirname(package_dir), 'documents')
|
||||
assert documents_root() == expected
|
||||
|
||||
|
||||
class TestDocumentPath:
|
||||
def test_a_relative_path_is_resolved_against_the_root(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path))
|
||||
|
||||
resolved = document_path(os.path.join(CONTRACTS_DIR, 'abc.pdf'))
|
||||
|
||||
assert resolved == str(tmp_path / CONTRACTS_DIR / 'abc.pdf')
|
||||
|
||||
def test_an_absolute_path_is_left_alone(self, tmp_path, monkeypatch):
|
||||
"""Rows written before this module existed. They must keep resolving
|
||||
to where the file actually is, or every contract uploaded so far
|
||||
becomes a 500 on download the day this ships."""
|
||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'new-root'))
|
||||
legacy = os.path.abspath(os.path.join('C:' + os.sep, 'old', 'place', 'abc.pdf'))
|
||||
|
||||
assert document_path(legacy) == legacy
|
||||
|
||||
def test_moving_the_root_moves_new_documents_and_not_old_ones(self, tmp_path, monkeypatch):
|
||||
relative = os.path.join(CONTRACTS_DIR, 'abc.pdf')
|
||||
legacy = os.path.abspath(os.path.join(str(tmp_path), 'legacy', 'abc.pdf'))
|
||||
|
||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'one'))
|
||||
first_new, first_old = document_path(relative), document_path(legacy)
|
||||
|
||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'two'))
|
||||
second_new, second_old = document_path(relative), document_path(legacy)
|
||||
|
||||
assert first_new != second_new, 'a release switch has to move new documents'
|
||||
assert first_old == second_old, 'and must not move the ones already filed'
|
||||
|
||||
|
||||
class TestThroughTheUploadRoute:
|
||||
@pytest.fixture
|
||||
def uploaded(self, app, client, as_role, make_user):
|
||||
"""A contract filed by an admin for a player."""
|
||||
as_role('admin')
|
||||
player_id = make_user('player')
|
||||
|
||||
response = client.post(
|
||||
'/users/contracts/upload',
|
||||
data={
|
||||
'player_id': str(player_id),
|
||||
'notes': 'Season contract',
|
||||
'contract_file': (_pdf(), 'contract.pdf'),
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return player_id
|
||||
|
||||
def test_the_stored_path_is_relative(self, app, uploaded):
|
||||
from app.models import Contract
|
||||
|
||||
with app.app_context():
|
||||
contract = Contract.query.one()
|
||||
assert not os.path.isabs(contract.file_path), (
|
||||
'an absolute path pins the file to the directory the process '
|
||||
'was started from — the one thing a release switch changes'
|
||||
)
|
||||
assert contract.file_path.startswith(CONTRACTS_DIR)
|
||||
|
||||
def test_the_file_lands_under_the_configured_root(self, app, uploaded):
|
||||
from app.models import Contract
|
||||
|
||||
with app.app_context():
|
||||
contract = Contract.query.one()
|
||||
resolved = document_path(contract.file_path)
|
||||
|
||||
assert os.path.exists(resolved)
|
||||
assert resolved.startswith(documents_root())
|
||||
|
||||
def test_it_can_be_downloaded_back(self, app, client, uploaded):
|
||||
from app.models import Contract
|
||||
|
||||
with app.app_context():
|
||||
contract_id = Contract.query.one().id
|
||||
|
||||
response = client.get(f'/users/contracts/{contract_id}/download')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data.startswith(b'%PDF-')
|
||||
|
||||
|
||||
def _pdf():
|
||||
"""The smallest thing pdf_upload_error accepts."""
|
||||
import io
|
||||
|
||||
return io.BytesIO(b'%PDF-1.4\n%%EOF\n')
|
||||
Reference in New Issue
Block a user