| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- import csv
- import json
- import os
- import pwd
- import re
- import subprocess
- import tempfile
- import time
- from datetime import datetime
- # ---------------------------------------------------------------------------
- # Backup
- # ---------------------------------------------------------------------------
- def backup_custom_dir(job, instance, backup_dir):
- """Sauvegarde un répertoire arbitraire au format compatible YunoHost."""
- cfg = json.loads(job.config_json or "{}")
- source_path = cfg.get("source_path", "").rstrip("/")
- excludes = cfg.get("excludes", [])
- if not source_path:
- raise ValueError("source_path manquant dans la configuration du job.")
- if not os.path.isabs(source_path):
- raise ValueError(f"source_path doit être un chemin absolu : {source_path}")
- from jobs.utils import unique_archive_name
- label = _slugify(job.name)
- base_name = f"{instance}_{label}_{datetime.utcnow().strftime('%Y%m%d')}"
- archive_name = unique_archive_name(base_name, backup_dir)
- archive_path = os.path.join(backup_dir, archive_name + ".tar")
- from flask import current_app
- instance_url = current_app.config.get("INSTANCE_URL", "")
- tmpdir = tempfile.mkdtemp(prefix="backupmanager_")
- try:
- # Répertoire de destination dans l'archive : data/custom{source_path}
- dest_in_archive = os.path.join(tmpdir, "data", "custom" + source_path)
- os.makedirs(dest_in_archive, exist_ok=True)
- # Copie avec sudo rsync (accès root pour lire tous les fichiers)
- rsync_cmd = ["sudo", "rsync", "-az", "--delete"]
- for exc in excludes:
- rsync_cmd += ["--exclude", exc]
- rsync_cmd += [source_path + "/", dest_in_archive + "/"]
- result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=7200)
- log = (result.stdout + result.stderr).strip()
- if result.returncode != 0:
- raise RuntimeError(f"rsync a échoué (code {result.returncode}) :\n{log}")
- # backup.csv (requis YunoHost)
- csv_path = os.path.join(tmpdir, "backup.csv")
- with open(csv_path, "w", newline="") as f:
- writer = csv.writer(f, delimiter=";")
- writer.writerow(["source", "dest"])
- writer.writerow([f"data/custom{source_path}", source_path])
- # backup_info.json (métadonnées BackupManager)
- info = {
- "instance_name": instance,
- "instance_url": instance_url,
- "type": "custom_dir",
- "source_path": source_path,
- "job_name": job.name,
- "created_at": datetime.utcnow().isoformat(),
- "backupmanager_version": "1.0.0",
- "restore": cfg.get("restore", {}),
- }
- info_path = os.path.join(tmpdir, "backup_info.json")
- with open(info_path, "w") as f:
- json.dump(info, f, indent=2)
- # Création du .tar via sudo (pour lire les fichiers root-owned dans tmpdir)
- result = subprocess.run(
- ["sudo", "tar", "-cf", archive_path, "-C", tmpdir,
- "backup.csv", "backup_info.json", "data"],
- capture_output=True, text=True, timeout=600,
- )
- if result.returncode != 0:
- raise RuntimeError(f"tar a échoué : {result.stderr.strip()}")
- # Rendre l'archive accessible à l'app
- subprocess.run(
- ["sudo", "chown", f"{_get_current_user()}:", archive_path],
- check=True,
- )
- # .info.json YunoHost — écrit dans tmpdir puis copié via sudo rsync
- from jobs.utils import sudo_getsize
- size = sudo_getsize(archive_path)
- ynh_info = {
- "created_at": int(time.time()),
- "description": f"BackupManager: custom_dir {source_path}",
- "size": size,
- "from_before_upgrade": False,
- "apps": {},
- "system": {},
- }
- tmp_ynh_info = os.path.join(tmpdir, archive_name + ".info.json")
- with open(tmp_ynh_info, "w") as f:
- json.dump(ynh_info, f, indent=2)
- subprocess.run(
- ["sudo", "rsync", tmp_ynh_info,
- os.path.join(backup_dir, archive_name + ".info.json")],
- capture_output=True,
- )
- finally:
- subprocess.run(["sudo", "rm", "-rf", tmpdir], check=False)
- return archive_name, log or "rsync terminé sans sortie."
- # ---------------------------------------------------------------------------
- # Restore
- # ---------------------------------------------------------------------------
- def restore_custom_dir(archive_name, backup_dir):
- """Restauration complète d'un custom_dir : fichiers + user + service + permissions."""
- archive_path = os.path.join(backup_dir, archive_name + ".tar")
- from jobs.utils import sudo_exists
- if not sudo_exists(archive_path):
- raise FileNotFoundError(f"Archive introuvable : {archive_path}")
- info = _read_backup_info(archive_path)
- source_path = info.get("source_path", "").rstrip("/")
- restore_cfg = info.get("restore", {})
- log_lines = []
- tmpdir = tempfile.mkdtemp(prefix="backupmanager_restore_")
- try:
- # Extraction complète dans tmpdir
- result = subprocess.run(
- ["sudo", "tar", "-xf", archive_path, "-C", tmpdir],
- capture_output=True, text=True, timeout=600,
- )
- if result.returncode != 0:
- raise RuntimeError(f"Extraction échouée : {result.stderr.strip()}")
- log_lines.append("Archive extraite.")
- extracted_data = os.path.join(tmpdir, "data", "custom" + source_path)
- if not os.path.isdir(extracted_data):
- raise RuntimeError(
- f"Chemin attendu absent dans l'archive : data/custom{source_path}"
- )
- # Créer le répertoire de destination si absent
- subprocess.run(["sudo", "mkdir", "-p", source_path], check=True)
- # Restauration des fichiers
- result = subprocess.run(
- ["sudo", "rsync", "-az", "--delete",
- extracted_data + "/", source_path + "/"],
- capture_output=True, text=True, timeout=7200,
- )
- if result.returncode != 0:
- raise RuntimeError(f"rsync restore a échoué : {result.stderr.strip()}")
- log_lines.append(f"Fichiers restaurés vers {source_path}.")
- # User système
- user_cfg = restore_cfg.get("system_user", {})
- if user_cfg.get("name"):
- _restore_system_user(user_cfg, log_lines)
- # Permissions
- perms_cfg = restore_cfg.get("permissions", {})
- if perms_cfg:
- _restore_permissions(source_path, perms_cfg, log_lines)
- # Service systemd
- service_cfg = restore_cfg.get("systemd_service", {})
- if service_cfg.get("name"):
- _restore_systemd_service(service_cfg, log_lines)
- # Commandes post-restauration
- post_cmds = restore_cfg.get("post_restore_commands", [])
- for cmd in post_cmds:
- _run_command(cmd, log_lines)
- finally:
- subprocess.run(["sudo", "rm", "-rf", tmpdir], check=False)
- return "\n".join(log_lines)
- def _restore_system_user(user_cfg, log_lines):
- name = user_cfg["name"]
- home = user_cfg.get("home", "/opt/" + name)
- shell = user_cfg.get("shell", "/bin/false")
- try:
- pwd.getpwnam(name)
- log_lines.append(f"Utilisateur système '{name}' déjà existant.")
- except KeyError:
- subprocess.run(
- ["sudo", "useradd",
- "--system",
- "--home-dir", home,
- "--no-create-home",
- "--shell", shell,
- name],
- check=True,
- )
- log_lines.append(f"Utilisateur système '{name}' créé.")
- def _restore_permissions(source_path, perms_cfg, log_lines):
- owner = perms_cfg.get("owner")
- mode = perms_cfg.get("mode")
- if owner:
- subprocess.run(["sudo", "chown", "-R", owner, source_path], check=True)
- log_lines.append(f"Propriétaire défini : {owner}.")
- if mode:
- subprocess.run(["sudo", "chmod", "-R", mode, source_path], check=True)
- log_lines.append(f"Permissions définies : {mode}.")
- def _restore_systemd_service(service_cfg, log_lines):
- name = service_cfg["name"]
- service_file = service_cfg.get("service_file", "")
- if service_file and os.path.exists(service_file):
- subprocess.run(["sudo", "systemctl", "daemon-reload"], check=False)
- log_lines.append("systemctl daemon-reload effectué.")
- subprocess.run(["sudo", "systemctl", "enable", name], check=False)
- result = subprocess.run(
- ["sudo", "systemctl", "start", name],
- capture_output=True, text=True,
- )
- if result.returncode == 0:
- log_lines.append(f"Service '{name}' activé et démarré.")
- else:
- log_lines.append(
- f"Service '{name}' : démarrage échoué — {result.stderr.strip()}"
- )
- def _run_command(cmd, log_lines):
- result = subprocess.run(
- cmd, shell=True, capture_output=True, text=True, timeout=120
- )
- out = (result.stdout + result.stderr).strip()
- status = "✓" if result.returncode == 0 else "✗"
- log_lines.append(f"{status} {cmd}" + (f"\n {out}" if out else ""))
- # ---------------------------------------------------------------------------
- # Utilitaires
- # ---------------------------------------------------------------------------
- def _slugify(s):
- return re.sub(r'[^a-z0-9]+', '-', s.lower().strip()).strip('-')
- def _get_current_user():
- import getpass
- return getpass.getuser()
- def _read_backup_info(archive_path):
- from jobs.utils import sudo_read_backup_info
- return sudo_read_backup_info(archive_path)
- def read_backup_info_from_dir(archive_name, backup_dir):
- """Utilisé par app.py pour afficher les infos de restauration."""
- from jobs.utils import sudo_read_backup_info
- archive_path = os.path.join(backup_dir, archive_name + ".tar")
- return sudo_read_backup_info(archive_path)
|