Dotfiles_ArchAP/bin/.local/bin/labfy-photo-backup

602 lines
26 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())