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.'))
|
||||||
4
start.sh
4
start.sh
@ -40,7 +40,9 @@ echo "Démarrage Django sur :1035"
|
|||||||
|
|
||||||
python3 manage.py migrate --noinput
|
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 &
|
python3 manage.py runserver 0.0.0.0:1035 &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user