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
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)
--no-secure
.