test
This commit is contained in:
parent
f3a7cf6337
commit
509daf05e6
172
backend/drive/management/commands/flatten_drive_root.py
Normal file
172
backend/drive/management/commands/flatten_drive_root.py
Normal 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.'))
|
||||
Loading…
x
Reference in New Issue
Block a user