602 lines
26 KiB
Python
Executable file
602 lines
26 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
"""Sauvegarde vérifiée de la photothèque vers un volume exFAT identifié."""
|
||
import argparse
|
||
from contextlib import closing
|
||
import ctypes
|
||
import datetime as dt
|
||
import errno
|
||
import fcntl
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import select
|
||
import signal
|
||
import sqlite3
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import unicodedata
|
||
import uuid
|
||
|
||
SCHEMA = 'labfy.photo-backup'
|
||
ACTIVE = {'planning', 'copying', 'verifying'}
|
||
CANCELLED = False
|
||
CHUNK = 8 * 1024 * 1024
|
||
|
||
|
||
def utcnow():
|
||
return dt.datetime.now(dt.timezone.utc).isoformat(timespec='seconds')
|
||
|
||
|
||
def atomic_write(path, content):
|
||
path = Path(path)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
# Conserver les chemins relatifs : mkstemp renvoie un chemin absolu,
|
||
# qui pourrait désigner le répertoire local après débranchement du SSD.
|
||
temporary = path.parent / ('.' + path.name + '.' + uuid.uuid4().hex + '.tmp')
|
||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||
try:
|
||
with os.fdopen(fd, 'wb') as stream:
|
||
stream.write(content)
|
||
stream.flush()
|
||
os.fsync(stream.fileno())
|
||
os.replace(temporary, path)
|
||
fd = os.open(path.parent, os.O_DIRECTORY | os.O_RDONLY)
|
||
try:
|
||
os.fsync(fd)
|
||
finally:
|
||
os.close(fd)
|
||
finally:
|
||
temporary.unlink(missing_ok=True)
|
||
|
||
|
||
def json_write(path, value):
|
||
atomic_write(path, (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode())
|
||
|
||
|
||
def load_json(path, fallback=None):
|
||
try:
|
||
return json.loads(Path(path).read_text())
|
||
except FileNotFoundError:
|
||
return fallback
|
||
|
||
|
||
def load_config(path):
|
||
config = load_json(path)
|
||
if not isinstance(config, dict) or config.get('version') != 1:
|
||
raise ValueError('Configuration de sauvegarde absente ou invalide.')
|
||
if not re.fullmatch(r'[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}', config.get('uuid', '')):
|
||
raise ValueError('UUID exFAT invalide.')
|
||
source = Path(config.get('source', ''))
|
||
if not source.is_absolute() or source == Path('/'):
|
||
raise ValueError('Le dossier source doit être un chemin absolu distinct de /.')
|
||
config['source'] = str(source)
|
||
config['uuid'] = config['uuid'].upper()
|
||
config['label'] = config.get('label', 'PHOTO_BAK')
|
||
config['state_dir'] = str(Path(config.get('state_dir',
|
||
Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) /
|
||
'labfy-photo-backup')).absolute())
|
||
return config
|
||
|
||
|
||
def unescape_mount(text):
|
||
return re.sub(r'\\([0-7]{3})', lambda m: chr(int(m.group(1), 8)), text)
|
||
|
||
|
||
def probe(config):
|
||
"""Lecture seule, notamment quand le SSD manque : aucun mkdir ici."""
|
||
result = {'disk_present': False, 'mounted': False, 'mountpoint': None,
|
||
'usb_speed_mbps': None}
|
||
link = Path('/dev/disk/by-uuid') / config['uuid']
|
||
try:
|
||
device = link.stat()
|
||
except FileNotFoundError:
|
||
return result
|
||
if not stat.S_ISBLK(device.st_mode):
|
||
raise ValueError('L’UUID ne désigne pas un périphérique bloc.')
|
||
result['disk_present'] = True
|
||
major_minor = f'{os.major(device.st_rdev)}:{os.minor(device.st_rdev)}'
|
||
sysdev = (Path('/sys/dev/block') / major_minor).resolve()
|
||
for parent in (sysdev, *sysdev.parents):
|
||
if (parent / 'idVendor').is_file() and (parent / 'speed').is_file():
|
||
try:
|
||
result['usb_speed_mbps'] = float((parent / 'speed').read_text().strip())
|
||
except (OSError, ValueError):
|
||
pass
|
||
break
|
||
matches = []
|
||
for line in Path('/proc/self/mountinfo').read_text().splitlines():
|
||
left, right = line.split(' - ', 1)
|
||
fields, fs = left.split(), right.split()
|
||
if fields[2] == major_minor and fields[3] == '/' and fs[0] == 'exfat':
|
||
matches.append({'mount_id': int(fields[0]),
|
||
'mountpoint': unescape_mount(fields[4]),
|
||
'rw': 'rw' in fields[5].split(','),
|
||
'device_number': device.st_rdev})
|
||
if len(matches) == 1 and matches[0]['rw']:
|
||
result.update(matches[0])
|
||
result['mounted'] = True
|
||
elif matches:
|
||
result['reason'] = 'Le montage est ambigu ou en lecture seule.'
|
||
return result
|
||
|
||
|
||
def process_token(pid):
|
||
try:
|
||
return Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()[19]
|
||
except (FileNotFoundError, ProcessLookupError):
|
||
return None
|
||
|
||
|
||
RSYNC_PROGRESS = re.compile(rb'^\s*([\d,]+)B\s+(\d+)%\s+([\d,]+)B/s')
|
||
|
||
|
||
def read_rsync_progress(path):
|
||
try:
|
||
data = Path(path).read_bytes()
|
||
except OSError:
|
||
return None
|
||
# Les lignes progress2 sont séparées par CR et peuvent être très nombreuses.
|
||
for line in reversed(re.split(rb'[\r\n]', data[-512 * 1024:])):
|
||
match = RSYNC_PROGRESS.match(line)
|
||
if match:
|
||
return {
|
||
'bytes': int(match.group(1).replace(b',', b'')),
|
||
'percent': int(match.group(2)),
|
||
'speed_bps': int(match.group(3).replace(b',', b'')),
|
||
}
|
||
return None
|
||
|
||
|
||
def get_status(config):
|
||
state_dir = Path(config['state_dir'])
|
||
saved = load_json(state_dir / 'status.json', {})
|
||
if not isinstance(saved, dict):
|
||
saved = {}
|
||
value = {'schema': SCHEMA, 'version': 1, 'phase': 'idle',
|
||
'progress_bytes': 0, 'total_bytes': 0, 'progress_percent': 0,
|
||
'error': None, 'last_success': None, 'message': 'Prêt à sauvegarder.'}
|
||
value.update(saved)
|
||
value.update({k: v for k, v in probe(config).items()
|
||
if k in {'disk_present', 'mounted', 'mountpoint', 'usb_speed_mbps', 'reason'}})
|
||
value['source'] = config['source']
|
||
value['label'] = config['label']
|
||
if value['phase'] in ACTIVE and (
|
||
process_token(value.get('pid', -1)) != value.get('process_token') or
|
||
value.get('process_token') is None):
|
||
value.update(phase='error', error='La sauvegarde précédente a été interrompue.',
|
||
message='Sauvegarde incomplète ; relancer lorsque le SSD est disponible.')
|
||
elif value['phase'] == 'copying' and value.get('run_id'):
|
||
run_dir = state_dir / 'runs' / value['run_id']
|
||
# Le catalogue est minuscule par rapport à la photothèque. Tant que son
|
||
# log n'existe pas, afficher exactement le progress2 de la grosse copie.
|
||
progress = read_rsync_progress(run_dir / 'Phototheque.rsync.log')
|
||
if progress:
|
||
done = progress['bytes']
|
||
percent = progress['percent']
|
||
estimated = max(done, round(done * 100 / percent)) if percent else value['total_bytes']
|
||
value.update(progress_bytes=done, total_bytes=estimated,
|
||
progress_percent=float(percent),
|
||
speed_bps=progress['speed_bps'])
|
||
return value
|
||
|
||
|
||
class State:
|
||
def __init__(self, config):
|
||
self.path = Path(config['state_dir']) / 'status.json'
|
||
self.value = get_status(config)
|
||
self.value.update(pid=os.getpid(), process_token=process_token(os.getpid()))
|
||
self.last_write = 0.0
|
||
|
||
def update(self, force=False, **values):
|
||
self.value.update(values)
|
||
total = self.value.get('total_bytes', 0)
|
||
self.value['progress_percent'] = min(100, round(
|
||
100 * self.value.get('progress_bytes', 0) / total, 1)) if total else 0
|
||
if force or time.monotonic() - self.last_write >= 1:
|
||
self.value['updated_at'] = utcnow()
|
||
json_write(self.path, self.value)
|
||
self.last_write = time.monotonic()
|
||
|
||
|
||
def cancellation_check():
|
||
if CANCELLED:
|
||
raise InterruptedError('Sauvegarde arrêtée ; les originaux sont conservés.')
|
||
|
||
|
||
def component_key(name):
|
||
if (not name or re.search(r'[<>:"\\|?*\x00-\x1f]', name) or
|
||
name.endswith((' ', '.')) or len(name.encode('utf-16-le')) // 2 > 255):
|
||
raise ValueError('Nom incompatible avec le SSD exFAT : ' + repr(name))
|
||
stem = name.split('.', 1)[0].upper()
|
||
if stem in {'CON', 'PRN', 'AUX', 'NUL'} or re.fullmatch(r'(COM|LPT)[1-9]', stem):
|
||
raise ValueError('Nom réservé sous Windows : ' + repr(name))
|
||
return unicodedata.normalize('NFC', name).casefold()
|
||
|
||
|
||
def file_identity(info):
|
||
return (info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_ino, info.st_dev)
|
||
|
||
|
||
def inventory(root, allow_empty=False):
|
||
root = Path(root)
|
||
info = root.lstat()
|
||
if not stat.S_ISDIR(info.st_mode):
|
||
raise ValueError('Le dossier source est absent, spécial ou symbolique : ' + str(root))
|
||
files = {}
|
||
def fail(exc):
|
||
raise exc
|
||
for directory, dirs, names in os.walk(root, followlinks=False, onerror=fail):
|
||
cancellation_check()
|
||
folded = {}
|
||
for name in dirs + names:
|
||
key = component_key(name)
|
||
if key in folded:
|
||
raise ValueError('Collision de noms exFAT : ' + str(Path(directory) / name))
|
||
folded[key] = name
|
||
p = Path(directory) / name
|
||
entry = p.lstat()
|
||
if stat.S_ISDIR(entry.st_mode):
|
||
continue
|
||
if not stat.S_ISREG(entry.st_mode):
|
||
raise ValueError('Lien ou fichier spécial non sauvegardé : ' + str(p))
|
||
files[p.relative_to(root).as_posix()] = file_identity(entry)
|
||
if not files and not allow_empty:
|
||
raise ValueError('La photothèque est vide ; vérifier le chemin source.')
|
||
return files
|
||
|
||
|
||
def safe_directory(path):
|
||
"""Chemin relatif au cwd du SSD épinglé, sans traversée symbolique."""
|
||
p = Path(path)
|
||
if p.is_absolute() or '..' in p.parts:
|
||
raise ValueError('Chemin de destination non relatif.')
|
||
current = Path('.')
|
||
for component in p.parts:
|
||
current /= component
|
||
try:
|
||
os.mkdir(current)
|
||
except FileExistsError:
|
||
if not stat.S_ISDIR(current.lstat().st_mode):
|
||
raise ValueError('Destination occupée par un lien ou fichier : ' + str(current))
|
||
|
||
|
||
def check_destination(root, source_files):
|
||
existing = inventory(root, allow_empty=True)
|
||
# Un nom déjà présent avec une casse différente ne doit pas être réutilisé.
|
||
paths = {}
|
||
all_existing = []
|
||
def fail(exc):
|
||
raise exc
|
||
for directory, dirs, names in os.walk(root, followlinks=False, onerror=fail):
|
||
all_existing.extend((Path(directory) / name).relative_to(root).as_posix()
|
||
for name in dirs + names)
|
||
for relative in all_existing + list(source_files):
|
||
parts = Path(relative).parts
|
||
for n in range(1, len(parts) + 1):
|
||
prefix = '/'.join(parts[:n])
|
||
key = '/'.join(component_key(x) for x in parts[:n])
|
||
prior = paths.setdefault(key, prefix)
|
||
if prior != prefix:
|
||
raise ValueError('Collision avec un nom existant sur le SSD : ' + prefix)
|
||
return existing
|
||
|
||
|
||
def mount_id_for_fd(fd):
|
||
for line in Path(f'/proc/self/fdinfo/{fd}').read_text().splitlines():
|
||
if line.startswith('mnt_id:'):
|
||
return int(line.split(':', 1)[1])
|
||
raise RuntimeError('Identité du montage indisponible.')
|
||
|
||
|
||
def open_destination(config):
|
||
detected = probe(config)
|
||
if not detected['mounted']:
|
||
raise OSError('SSD PHOTO_BAK absent ou non monté en écriture.')
|
||
fd = os.open(detected['mountpoint'], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||
try:
|
||
if (os.fstat(fd).st_dev != detected['device_number'] or
|
||
mount_id_for_fd(fd) != detected['mount_id']):
|
||
raise OSError('Le montage du SSD a changé pendant sa vérification.')
|
||
check = probe(config)
|
||
if not check['mounted'] or check.get('mount_id') != detected['mount_id']:
|
||
raise OSError('Le SSD a été débranché pendant sa vérification.')
|
||
return fd, detected
|
||
except BaseException:
|
||
os.close(fd)
|
||
raise
|
||
|
||
|
||
def sync_filesystem(fd):
|
||
libc = ctypes.CDLL(None, use_errno=True)
|
||
if libc.syncfs(fd) != 0:
|
||
code = ctypes.get_errno()
|
||
raise OSError(code, os.strerror(code))
|
||
|
||
|
||
def snapshot_catalogues(target):
|
||
home = Path.home()
|
||
databases = {
|
||
'digiKam/digikam4.db': home / '.local/share/digikam/digikam4.db',
|
||
'digiKam/recognition.db': home / '.local/share/digikam/recognition.db',
|
||
'darktable/library.db': home / '.config/darktable/library.db',
|
||
'darktable/data.db': home / '.config/darktable/data.db',
|
||
}
|
||
for relative, original in databases.items():
|
||
if not original.is_file():
|
||
continue
|
||
destination = target / relative
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
deadline = time.monotonic() + 60
|
||
def progress(status, remaining, total):
|
||
cancellation_check()
|
||
if time.monotonic() > deadline:
|
||
raise TimeoutError('Catalogue occupé trop longtemps : ' + original.name)
|
||
with closing(sqlite3.connect(original.resolve().as_uri() + '?mode=ro', uri=True,
|
||
timeout=3)) as source, closing(sqlite3.connect(destination)) as copy:
|
||
source.backup(copy, pages=1024, progress=progress, sleep=0.1)
|
||
if copy.execute('PRAGMA quick_check').fetchone() != ('ok',):
|
||
raise OSError('Vérification du catalogue échouée : ' + original.name)
|
||
for relative, original in {
|
||
'digiKam/digikamrc': home / '.config/digikamrc',
|
||
'darktable/darktablerc': home / '.config/darktable/darktablerc',
|
||
}.items():
|
||
if original.is_file():
|
||
destination = target / relative
|
||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||
resolved = original.resolve()
|
||
fd = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW)
|
||
try:
|
||
before = file_identity(os.fstat(fd))
|
||
data = bytearray()
|
||
while block := os.read(fd, CHUNK):
|
||
cancellation_check()
|
||
data.extend(block)
|
||
if (file_identity(os.fstat(fd)) != before or original.resolve() != resolved or
|
||
file_identity(original.stat()) != before):
|
||
raise OSError('Configuration modifiée pendant la sauvegarde : ' + original.name)
|
||
finally:
|
||
os.close(fd)
|
||
atomic_write(destination, data)
|
||
return inventory(target, allow_empty=True)
|
||
|
||
|
||
def rsync_copy(source, destination, files, run_dir, run_id, state, base, total):
|
||
filelist = run_dir / (destination + '.files0')
|
||
atomic_write(filelist, b''.join(os.fsencode(name) + b'\0' for name in sorted(files)))
|
||
args = ['rsync', '-rt', '--checksum', '--modify-window=1', '--backup',
|
||
'--backup-dir=../Historique/' + run_id + '/' + destination,
|
||
'--partial-dir=.rsync-partial', '--fsync', '--info=progress2',
|
||
'--outbuf=L', '--from0', '--files-from=' + str(filelist), '--',
|
||
str(source) + '/', './' + destination + '/']
|
||
env = dict(os.environ, LC_ALL='C')
|
||
pattern = re.compile(rb'^\s*([\d,]+)B\s+(\d+)%\s+')
|
||
with (run_dir / (destination + '.rsync.log')).open('wb') as log:
|
||
child = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=log,
|
||
stdin=subprocess.DEVNULL, env=env, bufsize=0)
|
||
pending = b''
|
||
try:
|
||
while True:
|
||
cancellation_check()
|
||
ready, _, _ = select.select([child.stdout], [], [], 1)
|
||
if ready:
|
||
data = os.read(child.stdout.fileno(), 16384)
|
||
if not data:
|
||
break
|
||
log.write(data)
|
||
pending += data
|
||
lines = re.split(rb'[\r\n]', pending)
|
||
pending = lines.pop()
|
||
for line in lines:
|
||
match = pattern.match(line)
|
||
if match:
|
||
done = int(match.group(1).replace(b',', b''))
|
||
state.update(progress_bytes=min(base + total, base + done))
|
||
elif child.poll() is not None:
|
||
break
|
||
code = child.wait()
|
||
if code:
|
||
raise OSError(f'Copie interrompue (rsync {code}). Le journal est dans {run_dir}.')
|
||
except BaseException:
|
||
if child.poll() is None:
|
||
child.terminate()
|
||
try:
|
||
child.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
child.kill()
|
||
child.wait()
|
||
raise
|
||
finally:
|
||
child.stdout.close()
|
||
state.update(force=True, progress_bytes=base + total)
|
||
|
||
|
||
def digest(path, expected=None, reread=False, progress=None):
|
||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||
try:
|
||
before = os.fstat(fd)
|
||
if not stat.S_ISREG(before.st_mode):
|
||
raise OSError('Fichier spécial rencontré pendant la vérification : ' + str(path))
|
||
if expected is not None and file_identity(before) != expected:
|
||
raise OSError('La source a changé : ' + str(path))
|
||
if reread and hasattr(os, 'posix_fadvise'):
|
||
try:
|
||
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
||
except OSError as exc:
|
||
if exc.errno not in (errno.EINVAL, errno.ENOSYS, errno.EOPNOTSUPP):
|
||
raise
|
||
result = hashlib.sha256()
|
||
while True:
|
||
cancellation_check()
|
||
block = os.read(fd, CHUNK)
|
||
if not block:
|
||
break
|
||
result.update(block)
|
||
if progress:
|
||
progress(len(block))
|
||
if file_identity(os.fstat(fd)) != file_identity(before):
|
||
raise OSError('Un fichier a changé pendant sa lecture : ' + str(path))
|
||
return result.hexdigest(), before.st_size
|
||
finally:
|
||
os.close(fd)
|
||
|
||
|
||
def notify(title, message, critical=False):
|
||
try:
|
||
subprocess.run(['notify-send', '--app-name=Sauvegarde photos',
|
||
'--icon=drive-removable-media',
|
||
'--urgency=' + ('critical' if critical else 'normal'),
|
||
title, message], check=False, timeout=5,
|
||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
pass
|
||
|
||
|
||
def run_backup(config):
|
||
state_dir = Path(config['state_dir'])
|
||
state_dir.mkdir(parents=True, exist_ok=True)
|
||
lock = (state_dir / 'backup.lock').open('a+b')
|
||
try:
|
||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except BlockingIOError:
|
||
lock.close()
|
||
print('Une sauvegarde est déjà en cours.', file=sys.stderr)
|
||
return 0
|
||
state = State(config)
|
||
old_cwd = os.open('.', os.O_RDONLY | os.O_DIRECTORY)
|
||
destination_fd = None
|
||
try:
|
||
state.update(force=True, phase='planning', progress_bytes=0, total_bytes=0,
|
||
error=None, message='Préparation et contrôle du SSD…')
|
||
destination_fd, mounted = open_destination(config)
|
||
# Tous les chemins SSD ci-dessous sont relatifs à ce montage épinglé.
|
||
os.fchdir(destination_fd)
|
||
source = Path(config['source'])
|
||
if source.stat().st_dev == os.fstat(destination_fd).st_dev:
|
||
raise ValueError('La source ne doit pas se trouver sur le SSD de sauvegarde.')
|
||
photos = inventory(source)
|
||
run_id = dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8]
|
||
run_dir = state_dir / 'runs' / run_id
|
||
run_dir.mkdir(parents=True)
|
||
catalogue_stage = run_dir / 'Catalogues'
|
||
catalogue_stage.mkdir()
|
||
catalogues = snapshot_catalogues(catalogue_stage)
|
||
roots = [(source, 'Phototheque', photos), (catalogue_stage, 'Catalogues', catalogues)]
|
||
total = sum(info[0] for _, _, entries in roots for info in entries.values())
|
||
count = sum(len(entries) for _, _, entries in roots)
|
||
required = 0
|
||
largest = 0
|
||
for _, destination, entries in roots:
|
||
safe_directory(destination)
|
||
old = check_destination(destination, entries)
|
||
for relative, info in entries.items():
|
||
largest = max(largest, info[0])
|
||
previous = old.get(relative)
|
||
if previous is None or previous[0] != info[0] or abs(previous[1] - info[1]) > 1_000_000_000:
|
||
required += info[0]
|
||
# Estimation de place ; un changement conservant date/taille peut aussi
|
||
# consommer de l'espace et fera échouer proprement rsync si nécessaire.
|
||
available = os.fstatvfs(destination_fd).f_bavail * os.fstatvfs(destination_fd).f_frsize
|
||
if available < max(required, largest) + 512 * 1024 * 1024:
|
||
raise OSError('Espace disponible insuffisant pour la sauvegarde et ses anciennes versions.')
|
||
safe_directory('Historique/' + run_id)
|
||
safe_directory('Verifications')
|
||
state.update(force=True, phase='copying', total_bytes=total, files_total=count,
|
||
files_done=0, run_id=run_id, message='Copie des photos et des catalogues…')
|
||
base = 0
|
||
for directory, destination, entries in roots:
|
||
size = sum(info[0] for info in entries.values())
|
||
if entries:
|
||
rsync_copy(directory, destination, entries, run_dir, run_id, state, base, size)
|
||
base += size
|
||
sync_filesystem(destination_fd)
|
||
state.update(force=True, phase='verifying', progress_bytes=0, files_done=0,
|
||
message='Relecture et vérification SHA-256 du SSD…')
|
||
manifest = []
|
||
done = 0
|
||
for directory, destination, entries in roots:
|
||
for relative, identity in sorted(entries.items()):
|
||
expected_hash, size = digest(directory / relative, expected=identity)
|
||
def advance(amount):
|
||
state.update(progress_bytes=state.value['progress_bytes'] + amount)
|
||
actual_hash, actual_size = digest(Path(destination) / relative,
|
||
reread=True, progress=advance)
|
||
if actual_hash != expected_hash or actual_size != size:
|
||
raise OSError('Vérification différente de la source : ' + destination + '/' + relative)
|
||
manifest.append({'path': destination + '/' + relative,
|
||
'size': size, 'sha256': expected_hash})
|
||
done += 1
|
||
state.update(files_done=done)
|
||
if inventory(source) != photos:
|
||
raise OSError('La photothèque a changé pendant la sauvegarde. Relancer pour la compléter.')
|
||
identity_now = probe(config)
|
||
if not identity_now['mounted'] or identity_now.get('mount_id') != mounted['mount_id']:
|
||
raise OSError('Le SSD a été débranché avant la validation finale.')
|
||
manifest_data = ''.join(json.dumps(row, ensure_ascii=False) + '\n' for row in manifest).encode()
|
||
local_manifest = run_dir / 'sha256.jsonl'
|
||
atomic_write(local_manifest, manifest_data)
|
||
atomic_write(Path('Verifications') / (run_id + '.sha256.jsonl'), manifest_data)
|
||
finished = utcnow()
|
||
report = {'schema': SCHEMA, 'version': 1, 'run_id': run_id, 'completed_at': finished,
|
||
'source': str(source), 'uuid': config['uuid'], 'files': count, 'bytes': total,
|
||
'manifest_sha256': hashlib.sha256(manifest_data).hexdigest(),
|
||
'verification': 'SHA-256 source et destination après syncfs ; cache évincé au mieux',
|
||
'policy': 'PC vers SSD ; fichiers retirés conservés ; anciennes versions dans Historique'}
|
||
json_write(Path('Verifications') / (run_id + '.json'), report)
|
||
json_write(Path('Verifications') / 'derniere-sauvegarde.json', report)
|
||
sync_filesystem(destination_fd)
|
||
identity_now = probe(config)
|
||
if not identity_now['mounted'] or identity_now.get('mount_id') != mounted['mount_id']:
|
||
raise OSError('Le SSD a été débranché avant l’enregistrement du résultat.')
|
||
json_write(run_dir / 'result.json', report)
|
||
state.update(force=True, phase='success', progress_bytes=total, files_done=count,
|
||
last_success=finished, error=None, message='Sauvegarde terminée et vérifiée.')
|
||
notify('Sauvegarde photos terminée', f'{count:,} fichiers vérifiés sur {config["label"]}.')
|
||
return 0
|
||
except Exception as exc:
|
||
state.update(force=True, phase='error', error=str(exc), message='Sauvegarde incomplète.')
|
||
notify('Sauvegarde photos incomplète', str(exc), critical=True)
|
||
print(str(exc), file=sys.stderr)
|
||
return 1
|
||
finally:
|
||
os.fchdir(old_cwd)
|
||
os.close(old_cwd)
|
||
if destination_fd is not None:
|
||
os.close(destination_fd)
|
||
lock.close()
|
||
|
||
|
||
def main():
|
||
default = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-photo-backup/config.json'
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument('action', choices=('status', 'run'))
|
||
parser.add_argument('--config', type=Path, default=default)
|
||
args = parser.parse_args()
|
||
def cancel(signum, frame):
|
||
global CANCELLED
|
||
CANCELLED = True
|
||
signal.signal(signal.SIGTERM, cancel)
|
||
signal.signal(signal.SIGINT, cancel)
|
||
try:
|
||
config = load_config(args.config)
|
||
if args.action == 'status':
|
||
print(json.dumps(get_status(config), ensure_ascii=False))
|
||
return 0
|
||
return run_backup(config)
|
||
except Exception as exc:
|
||
if args.action == 'status':
|
||
print(json.dumps({'schema': SCHEMA, 'version': 1, 'disk_present': False,
|
||
'mounted': False, 'phase': 'error', 'error': str(exc),
|
||
'message': 'Configuration indisponible.', 'last_success': None,
|
||
'progress_bytes': 0, 'total_bytes': 0, 'progress_percent': 0},
|
||
ensure_ascii=False))
|
||
else:
|
||
print(str(exc), file=sys.stderr)
|
||
return 1
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|