[script] sauvegarde de notre config système

Autres projets et contributions
Avatar de l’utilisateur
papajoke
Elfe
Messages : 788
Inscription : sam. 30 août 2014, 19:54

[script] sauvegarde de notre config système

Message par papajoke »

Bonjour

Un petit script maison pour sauvegarder notre configuration système. Permet aussi de sauvegarder quelques configurations d'un utilisateur mais ce n'est pas son premier but car existe pas mal de script pour la gestion des "dot" files.


Le lancer sans paramètre va créer automatiquement une archive (.tar.xy) dans notre répertoire courant.
- taille sans doute de moins de 100 ko
- par sécurité, l'archive générée est uniquement en lecture par root (on a souvent des mots de passe et peut-être des clés privées)

Pour personnaliser notre sauvegarde, il est possible de lui ajouter une liste statique de fichiers/répertoires en paramètre. Dans cette liste, on peut utiliser $USER. Cet utilisateur peut-être forcé avec l'option -u.

Sauvegarde :
- fichiers inclus en manuel (voir `FORCES` dans code source et option -f)
- automatiquement: .conf modifiés de nos paquets et fichiers n'appartenant pas à un paquet

sudo ./main.py -f fichiers.list

Code : Tout sélectionner

# fichier.list
  /home/{user}/workspace/python/archlinux/save-conf/main.py
    # /tuc/machin.bidule
    
/home/$USER/workspace/python/archlinux/save-conf/pyproject.toml
Documentation / aide mémoire:
Pour voir les optionss/paramètres, utiliser le classique -h : script -h et script COMMANDE -h

----------

pratique ! Le script permet en plus de comparer notre "ancienne" archive à notre configuration actuelle (commande list)

Code : Tout sélectionner

sudo ./main.py list -a arch.config-system.save.2024-12-15.tar.xz 
/home/patrick/.ssh/environment
/home/patrick/.config/uv/uv.toml
# 2 fichiers modifiés sur 103

Code : Tout sélectionner

sudo ./main.py list -a arch.config-system.save.2024-12-15.tar.xz --no-comment
/home/patrick/.ssh/environment
# 1 fichier modifié sur 103

----------


Pour affiner la commande "list", il permet aussi de comparer 2 fichiers (commande check) (dépendance : git)
- archive / fichier local
- archive / fichier dans un paquet du cache de pacman (--arch)
# - fichier local / paquet dans le cache de pacman (--pkg) Ici aucun lien avec archive donc sans utilité directe avec ce script. Plus dans optique .pacnew ?

Code : Tout sélectionner

sudo ./main.py check -a arch.config-system.save.2024-12-14.tar.xz -f /etc/pacman.conf
diff --git a/tmp/archive.pacman.conf b/etc/pacman.conf
index fcdebbb..8c41955 100644
--- a/tmp/archive.pacman.conf
+++ b/etc/pacman.conf
@@ -23 +23 @@ Architecture = auto
-
+IgnorePkg=archlinux.fr  # pour démo


Fichier écrit en python, mais il est possible de le réécrite pour son propre besoin en bash puisque qu'il utilise à 90% des commandes shell
Puisque python est enseigné au lycée, sans doute que le code source est plus simple a personnaliser que du bash ...

Code : Tout sélectionner

#!/usr/bin/env python

import argparse
import datetime
import tarfile
import os
from pathlib import Path
import subprocess
import sys

VERSION = "0.9.0" # 21/12/2024

NOM_ARCHIVE = "arch.config-system.save."

user = os.environ.get("SUDO_USER")  # OK - Uniquement avec sudo et run0
# ou: script.py -u root

"""
Eventuellement on ajoute namuellement quelques fichiers d'un home (ou système)
TODO: a personnaliser
ou :
script.py -f fichiers.list
"""
# "/" à la fin => uniquement pour m'indiquer que c'est un répertoire
FORCES = [
    "/home/{user}/.ssh/",
    "/home/{user}/.makepkg.conf",
    "/home/{user}/.gitconfig",
    "/home/{user}/.profile",
    "/home/{user}/.zshrc",
    "/home/{user}/.bashrc",
    "/home/{user}/.face.icon",
    "/home/{user}/.config/fish/config.fish",
    "/home/{user}/.config/environment.d/",
    "/home/{user}/.config/git/",
    "/home/{user}/.config/yay/",
    "/home/{user}/.config/systemd/",
    "/home/{user}/.config/autostart/",
]


def get_modified():
    """fichiers issus de pacman mais modifiés"""
    excludes = (
        "",
        "/etc/locale.gen",
        "/etc/shiny-mirrors.conf",
        "/var/lib/PackageKit/transactions.db",
        "/usr/share/icons/default/index.theme",
    )
    output = subprocess.run(
        r"pacman -Qii | awk '/\[modified\]/ {print $(NF-1)}'",
        capture_output=True,
        text=True,
        shell=True,
        check=True,
    ).stdout.strip()
    modifieds = output.splitlines()
    for exclude in excludes:
        if exclude in modifieds:
            modifieds.remove(exclude)
    return modifieds


def get_news():
    """recherche des fichiers créés par l'administrateur"""
    excludes = ("", "/etc/mkinitcpio.d", "/etc/fonts/conf.d")
    files = []
    tests = []

    # cherche répertoires de configuration classiques  "*.d"
    output = subprocess.run(
        r"find /etc -path '*.d'",
        capture_output=True,
        text=True,
        shell=True,
        check=True,
    ).stdout.strip()
    for dir_ in (Path(d) for d in output.splitlines()):
        if not dir_.is_dir() or str(dir_) in excludes or not str(dir_).endswith(".d"):
            continue
        for file_ in dir_.iterdir():
            if str(file_).startswith("/etc/pacman.d/gnupg"):
                continue
            tests.append(str(file_))

    # ne garde que fichiers non pacman
    proc = subprocess.run(
        f"LANG=en pacman -Qo {' '.join(tests)} >/dev/null",
        text=True,
        shell=True,
        check=False,
        capture_output=True,
    )
    for line in proc.stderr.strip().splitlines():
        files.append(" ".join(line.split(" ")[4:]))

    # des services systemd perso ?
    # BUG si perso mais lien vers ... -> préférer add to tests puis pacman -O
    for file_ in Path("/etc/systemd/system/").glob("*.service"):
        if file_.is_symlink() or not file_.is_file():
            continue
        files.append(str(file_))
    for file_ in Path("/etc/systemd/system/").glob("*.timer"):
        if file_.is_symlink() or not file_.is_file():
            continue
        files.append(str(file_))
    return files


def make_archive(file_name: str, files) -> Path:
    """créer un nouveau fichier archive"""
    COMP = "xz"
    archive = Path(f"{file_name}.tar.{COMP}")
    archive.unlink(missing_ok=True)
    # print(files)
    with tarfile.open(archive, mode=f"w:{COMP}") as htar:
        for file_ in files:
            try:
                htar.add(file_)
            except FileNotFoundError as err:
                print("# Alerte :", err, file=sys.stderr)
    return archive.resolve()


def read_file(file_name: Path, from_file: Path) -> Path | None:
    """Mettre dans /tmp/ fichier venant du disque ou archive ou paquet du cache pacman"""
    file_tmp = None
    ext = from_file.suffix if from_file else ""
    match ext:
        case ".xz":
            with tarfile.open(from_file, mode="r") as htar:
                try:
                    content = htar.extractfile(str(file_name)[1:]).read().decode()
                except KeyError:
                    print(f"Fichier {file_name} non présent dans archive - {from_file}")
                    exit(2)
            file_tmp = Path(f"/tmp/archive.{file_name.name}")
        case ".zst":
            content = subprocess.run(
                f"tar -axf {from_file} '{str(file_name)[1:]}' -O",
                shell=True,
                capture_output=True,
                text=True,
            ).stdout
            if not content:
                print(f"Fichier {file_name} non présent dans paquet du cache - {from_file}")
                exit(2)
            file_tmp = Path(f"/tmp/pkg.cache.{file_name.name}")
        case _:
            content = file_name.read_text()
            file_tmp = Path(f"/tmp/{file_name.name}")
    if content and file_tmp:
        file_tmp.write_text(content)
    return file_tmp


def comp_file(archive_file: Path, file_conf: Path) -> bool:
    """comparer un fichier sur disque avec celui dans archive"""
    try:
        content = file_conf.read_text()
    except UnicodeDecodeError:
        # binaire !
        with tarfile.open(archive_file, mode=f"r:{archive_file.suffix[1:]}") as htar:
            try:
                if file_conf.read_bytes() != htar.extractfile(str(file_conf)[1:]).read():
                    print(f"{file_conf} (bin) modifié")
                    return 3
            except KeyError:
                print(f"Fichier non présent dans archive - {file_conf}")
                return 2
        return 0

    content_archive = "?"
    with tarfile.open(archive_file, mode=f"r:{archive_file.suffix[1:]}") as htar:
        try:
            content_archive = htar.extractfile(str(file_conf)[1:]).read().decode()
        except KeyError:
            print(f"Fichier non présent dans archive - {file_conf}")
            return 2
    if [l for l in content.split() if l] == [l for l in content_archive.split() if l]:  # ignore lignes vides
        return 0

    file_tmp = Path(f"/tmp/archive.{file_conf.name}")
    file_tmp.write_text(content_archive)
    subprocess.run(
        f"git --no-pager diff --unified=0 --ignore-blank-lines --no-index --color=always -- {str(file_tmp)} {file_conf}",
        shell=True,
        check=False,
    )
    file_tmp.unlink(missing_ok=True)
    return 3


def list_archive(archive_file: Path):
    """liste des fichiers dans l'archive"""
    with tarfile.open(archive_file, mode=f"r:{archive_file.suffix[1:]}") as htar:
        for tarinfo in htar:
            if tarinfo.isfile():
                yield tarinfo.name, htar


def get_pkg(file_conf: Path):
    """extraire fichier contenu dans le paquet du cache pacman"""
    pkg = subprocess.run(f"pacman -Qoq {file_conf}", shell=True, capture_output=True, text=True).stdout.strip()
    if not pkg:
        exit(4)
    cache = subprocess.run(f"pacman -S --print {pkg}", shell=True, capture_output=True, text=True).stdout.strip()
    cache = Path(cache.removeprefix("file://"))
    if not cache or not cache.exists():
        exit(5)
    return read_file(file_conf, cache)


### commandes


def list_action(args):
    if os.geteuid() != 0 and not args.list:
        print("A utiliser uniquement en mode administrateur !\n")
        exit(3)
    modified = 0
    no_comments = args.no_comment
    for count, [file_name, htar] in enumerate(list_archive(args.archive), start=1):
        if args.list:
            print(file_name)
            continue
        file_ = Path(f"/{file_name}")
        # print("...file:", file_)
        try:
            if args.no_comment:
                content = [l for l in file_.read_text().split("\n") if l and not l.startswith("#")]
                content_archive = [
                    l for l in htar.extractfile(file_name).read().decode().split("\n") if l and not l.startswith("#")
                ]
                content = " ".join(content).split()
                content_archive = " ".join(content_archive).split()
            else:
                content = file_.read_text().split()
                content_archive = htar.extractfile(file_name).read().decode().split()
            if [l for l in content if l] != [l for l in content_archive if l]:
                print(f"/{file_name}")
                modified += 1
        except UnicodeDecodeError:
            # binaire: file_name
            # print(f"# {file_name} (bin) ...")
            if file_.read_bytes() != htar.extractfile(file_name).read():
                print(f"/{file_name} (bin)")
                modified += 1
    if args.list:
        print(f"# {count} fichiers dans la sauvegarde")
    else:
        print(f"# {modified} fichier{'s' if modified > 1 else ''} modifié{'s' if modified > 1 else ''} sur {count}")
    exit(6 if modified else 0)


def cmp_action(args):
    if args.pkg:
        if tmp := get_pkg(args.file):
            print("#Comparer fichier disque au paquet dans le cache pacman\n")
            subprocess.run(
                f"git --no-pager diff --unified=0 --ignore-blank-lines --no-index --color=always -- {tmp} {args.file}",
                shell=True,
                check=False,
            )
            tmp.unlink(missing_ok=True)
        exit(0)
    if args.arch:
        tmp_archive = read_file(args.file, args.archive)
        if tmp := get_pkg(args.file):
            print("#Comparer fichier archive au fichier du paquet dans le cache pacman\n")
            subprocess.run(
                f"git --no-pager diff --unified=0 --ignore-blank-lines --no-index --color=always -- {tmp} {tmp_archive}",
                shell=True,
                check=False,
            )
            tmp.unlink(missing_ok=True)
        tmp_archive.unlink(missing_ok=True)
        exit(0)
    ok = comp_file(args.archive, args.file)
    exit(ok)


def make_action(args):
    if os.geteuid() != 0:
        print("ERREUR ! A utiliser uniquement en mode administrateur\n")
        parser.print_help()
        exit(3)

    forces = FORCES
    user = args.user
    if args.file:
        content = args.file.read_text().split()
        forces += (l.strip() for l in content if l.strip() and not l.lstrip().startswith("#"))

    for key, value in enumerate(forces):
        forces[key] = value.replace("$USER", user).format(user=user)

    modifieds = get_modified()
    print(f"# {len(modifieds)} Modifiés:")
    print("\n".join(modifieds), end="\n\n")

    news = get_news()
    print(f"# {len(news)} ajouts:")
    print("\n".join(news), end="\n\n")

    print(f"# {len(forces)} personnalisés", end="\n\n")
    # print("\n".join(forces), end="\n\n")

    name = f"{NOM_ARCHIVE}{datetime.datetime.now().strftime('%Y-%m-%d')}"
    archive = make_archive(name, modifieds + news + forces)
    if not args.no_secure:  # pas dans doc ;)
        archive.chmod(0o600)  # lecture pour uniquement root (par défaut)
    print("# Sauvegarde dans archive:", archive)


def archive_path_arg(path):
    path = Path(path)
    if path.suffixes[-2:] != [".tar", ".xz"]:
        raise argparse.ArgumentTypeError(f"Archive '{path}' n'est pas un format valide")
    try:
        with path.open(mode="r"):
            pass
    except:
        raise argparse.ArgumentTypeError(f"Archive '{path}' n'est pas accessible en lecture")
    return path


def fichier_path_arg(path):
    path = Path(path)
    if not path.is_file():
        raise argparse.ArgumentTypeError(f"Fichier config: {path} non trouvé")
    try:
        with path.open(mode="r"):
            pass
    except:
        raise argparse.ArgumentTypeError(f"Fichier config: {path} n'est pas accessible en lecture")
    return path


def get_argparser(user) -> argparse.ArgumentParser:
    class LongHelpFormatter(argparse.ArgumentDefaultsHelpFormatter):
        def __init__(self, prog, indent_increment=2, max_help_position=32, width=None):
            super().__init__(prog, indent_increment, max_help_position, width)

    parser = argparse.ArgumentParser(
        Path(__file__).name,
        formatter_class=LongHelpFormatter,
        description="Sauvegarde de la configuration archlinux, sans paramètre: créer l'archive",
        epilog="Option `--file` : liste de fichiers avec `$USER` ou `{user}` supportés",
    )
    parser.add_argument("-u", "--user", help="Utilisateur", default=user)
    parser.add_argument("-f", "--file", help="Liste de fichiers à forcer", type=fichier_path_arg)
    parser.add_argument("--no-secure", help=argparse.SUPPRESS, action="store_true", default=False)
    parser.set_defaults(func=make_action)
    subparsers = parser.add_subparsers()
    check = subparsers.add_parser(
        "check",
        help="Comparer un fichier de l'archive/disque (utilise git)",
        formatter_class=LongHelpFormatter,
        epilog="Par défaut compare un fichier disque à l'archive",
    )
    check.add_argument(
        "-a",
        "--archive",
        help="Fichier archive tar.xz",
        type=archive_path_arg,
        required="--pkg" not in sys.argv,
        metavar="FILE",
    )
    check.add_argument(
        "-f",
        "--file",
        help="Fichier disque de configuration",
        type=fichier_path_arg,
        required=True,
        metavar="FILE",
    )
    check.add_argument(
        "--pkg",
        help="comparer un fichier disque au fichier du paquet dans le cache",
        default=False,
        action="store_true",
    )
    check.add_argument(
        "--arch",
        help="comparer un fichier de l'archive au fichier du paquet dans le cache",
        default=False,
        action="store_true",
    )
    check.set_defaults(func=cmp_action)
    listed = subparsers.add_parser(
        "list",
        help="Lister une archive",
        formatter_class=LongHelpFormatter,
        epilog="Par défaut liste uniquement les fichiers de l'archive modifiés sur disque",
    )
    listed.add_argument(
        "--list",
        help="Lister une archive tar.xz",
        default=False,
        action="store_true",
    )
    listed.add_argument(
        "-a",
        "--archive",
        help="Fichier archive tar.xz",
        type=archive_path_arg,
        required=True,
        metavar="FILE",
    )
    listed.add_argument(
        "--no-comment",
        help="Exclure les lignes de commentaire pour comparaison (débute par #)",
        default=False,
        action="store_true",
    )
    listed.set_defaults(func=list_action)
    return parser


if __name__ == "__main__":
    parser = get_argparser(user)
    args = parser.parse_args()
    args.func(args)
Note : uniquement pour tester le script, il est possible de dévalider le chmod 600 sur l'archive (+ simple pour lire son contenu en gui) avec l'option --no-secure.
Répondre