This commit is contained in:
Louis Mazin 2026-10-14 01:41:03 +02:00
parent f3a7cf6337
commit 509daf05e6
2 changed files with 175 additions and 1 deletions

View File

@ -0,0 +1,172 @@
"""Move the legacy physical /storage/root layer into /storage.
This is deliberately an operator-triggered maintenance operation: the application
must be stopped and both the database and the storage must be backed up first.
A journal permits safely retrying if the process is interrupted between filesystem
renames and the atomic database update.
"""
import json
import os
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import F
from django.db.models.functions import Substr
from drive.models import DriveItem, DriveSpace
JOURNAL_NAME = '.gooddrive-flatten-root.json'
PREFIX = 'root/'
def present(path):
"""Like exists(), but also detect dangling symlinks so they cannot be overwritten."""
return path.exists() or path.is_symlink()
class Command(BaseCommand):
help = ('Supprime le dossier physique /root hérité des anciennes versions. '
'Sans --apply, effectue uniquement les vérifications.')
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true',
help='Effectue les déplacements et met à jour les chemins SQL.')
def handle(self, *args, **options):
storage = Path(settings.DRIVE_STORAGE_ROOT).resolve()
old_root = storage / 'root'
journal = storage / JOURNAL_NAME
apply = options['apply']
root = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).order_by('pk').first()
if root is None:
self.stdout.write('Aucun espace racine initialisé : rien à modifier.')
return
if root.relative_path not in ('root', ''):
raise CommandError(f'Chemin racine inattendu : {root.relative_path!r}.')
if old_root.is_symlink() or (present(old_root) and not old_root.is_dir()):
raise CommandError('Le chemin root est un lien symbolique ou n’est pas un dossier.')
if present(journal):
try:
state = json.loads(journal.read_text(encoding='utf-8'))
except (OSError, ValueError) as exc:
raise CommandError('Journal de migration illisible : ne pas le supprimer sans vérification.') from exc
names = state.get('entries')
if (state.get('version') != 1 or state.get('root_id') != root.pk
or not isinstance(names, list) or len(names) != len(set(names))
or any(not isinstance(name, str) or not name or name in ('.', '..')
or '/' in name or '\\' in name or '\x00' in name for name in names)):
raise CommandError('Journal de migration invalide : intervention manuelle nécessaire.')
resumed = True
else:
names = sorted(path.name for path in old_root.iterdir()) if old_root.exists() else []
resumed = False
if root.relative_path == '':
# Previous attempt may have committed SQL but been interrupted before
# removing the journal. Do not move untracked content in this state.
if journal.exists():
for name in names:
if present(old_root / name) or not present(storage / name):
raise CommandError('La base est migrée, mais les déplacements sont incomplets : contrôle manuel nécessaire.')
if old_root.exists() and any(old_root.iterdir()):
raise CommandError('Le dossier root contient encore des éléments : intervention manuelle nécessaire.')
if apply:
if old_root.exists():
old_root.rmdir()
journal.unlink()
self.stdout.write(self.style.SUCCESS('Migration déjà appliquée ; journal nettoyé.'))
else:
self.stdout.write('Base déjà migrée. --apply nettoiera le journal restant.')
elif old_root.exists():
if any(old_root.iterdir()):
raise CommandError('La base utilise déjà /storage, mais /storage/root contient des données non suivies. Aucun déplacement automatique.')
if apply:
old_root.rmdir()
self.stdout.write(self.style.SUCCESS('Dossier root vide supprimé.'))
else:
self.stdout.write('Base déjà migrée. --apply supprimera le dossier root vide.')
else:
self.stdout.write(self.style.SUCCESS('Déjà configuré : la racine est directement /storage.'))
return
# Only normalize the legacy root and descendants. Other spaces and
# existing physical paths are never overwritten or merged implicitly.
spaces = list(DriveSpace.objects.all().order_by('pk'))
changing = [s for s in spaces if s.pk == root.pk or s.relative_path.startswith(PREFIX)]
other_paths = {s.relative_path.casefold() for s in spaces if s not in changing}
new_paths = ['' if s.pk == root.pk else s.relative_path[len(PREFIX):] for s in changing]
if (any(p.startswith('/') or not p or p.startswith('../') or '/..' in p
for p in new_paths if p != '')
or len({p.casefold() for p in new_paths}) != len(new_paths)
or any(p.casefold() in other_paths for p in new_paths)):
raise CommandError('Collision ou chemin invalide parmi les espaces SQL : aucune modification.')
# A referenced physical file cannot be lost due to a typo or an
# already-missing root. Empty storage_location is legacy and is left as-is.
if not resumed:
for location in DriveItem.objects.filter(is_folder=False, storage_location__startswith=PREFIX).values_list('storage_location', flat=True).iterator():
if not location.startswith(PREFIX) or '\\' in location or '..' in Path(location).parts:
raise CommandError(f'Chemin de fichier invalide en base : {location!r}.')
if not (storage / location).is_file():
raise CommandError(f'Fichier référencé manquant : {location!r}. Aucun déplacement.')
for name in names:
source, target = old_root / name, storage / name
if present(source) and source.is_symlink():
raise CommandError(f'Lien symbolique dans root : {name!r}. Migration refusée.')
if present(source) and present(target):
raise CommandError(f'Collision : {source} et {target} existent déjà. Aucun écrasement.')
if not present(source) and (not resumed or not present(target)):
raise CommandError(f'Élément introuvable : {source}. Migration interrompue.')
count_files = DriveItem.objects.filter(storage_location__startswith=PREFIX).count()
self.stdout.write(f'Racine actuelle : {storage}/root -> {storage}')
self.stdout.write(f'{len(names)} élément(s) physiques ; {len(changing)} espace(s) ; {count_files} chemin(s) de fichier à modifier.')
if not apply:
self.stdout.write(self.style.WARNING('Simulation uniquement. Arrête GoodDrive, sauvegarde SQL et /storage, puis lance --apply.'))
return
if not resumed:
# Journal persisted before the first rename, for retry after a crash.
state = {'version': 1, 'root_id': root.pk, 'entries': names}
try:
with journal.open('x', encoding='utf-8') as stream:
json.dump(state, stream, ensure_ascii=False)
stream.flush()
os.fsync(stream.fileno())
except FileExistsError as exc:
raise CommandError('Une autre migration est déjà en cours.') from exc
try:
for name in names:
source, target = old_root / name, storage / name
if present(source):
if present(target):
raise CommandError(f'Destination occupée : {target}. Migration interrompue.')
source.rename(target) # same filesystem: does not copy large files
if old_root.exists():
old_root.rmdir() # refuses unexpected new files; do not erase content
# Verify the new physical destinations before committing the SQL paths.
for location in DriveItem.objects.filter(
is_folder=False, storage_location__startswith=PREFIX
).values_list('storage_location', flat=True).iterator():
if not (storage / location[len(PREFIX):]).is_file():
raise CommandError(f'Fichier inaccessible après déplacement : {location!r}.')
with transaction.atomic():
# Leave descendants coherent with the root; update paths with
# explicit IDs, not by renaming actual DriveItems (logical folders).
for space, new_path in zip(changing, new_paths):
DriveSpace.objects.filter(pk=space.pk).update(relative_path=new_path)
DriveItem.objects.filter(storage_location__startswith=PREFIX).update(
storage_location=Substr(F('storage_location'), len(PREFIX) + 1))
journal.unlink()
except Exception as exc:
raise CommandError(
'Conversion interrompue : journal conservé. Laisse GoodDrive arrêté '
f'et relance la même commande --apply après résolution. Détail : {exc}'
) from exc
self.stdout.write(self.style.SUCCESS('Terminé : /storage est la racine physique, sans dossier root.'))

View File

@ -40,7 +40,9 @@ echo "Démarrage Django sur :1035"
python3 manage.py migrate --noinput
python manage.py ensure_admin
python3 manage.py ensure_admin
python3 manage.py flatten_drive_root
python3 manage.py runserver 0.0.0.0:1035 &
BACKEND_PID=$!