arch-system/scripts/deploy.sh

529 lines
19 KiB
Bash
Executable file

#!/usr/bin/env bash
set -Eeuo pipefail
readonly REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DO_PACKAGES=0 DO_CONFIGS=0 DO_TOOLS=0 DO_SERVICES=0 DRY_RUN=0
BACKUP_DIR='' TEMP_DIR='' BENEFICIARY='' MACHINE_NAME='' DISPLAY_USER=''
CURRENT_STAGE='précontrôles' CURRENT_ACTION=''
COMPLETED=() APPLIED=() OFFICIAL_PACKAGES=() FOREIGN_PACKAGES=()
SYSTEM_SERVICES=() SYSTEM_TIMERS=() ON_DEMAND_SERVICES=()
readonly CONFIG_SOURCES=(
rootfs/etc/pacman.conf rootfs/etc/makepkg.conf rootfs/etc/mkinitcpio.conf
rootfs/etc/nftables.conf rootfs/etc/limine-snapper-sync.conf
rootfs/etc/tlp.d/10-archasp.conf rootfs/etc/systemd/zram-generator.conf
rootfs/etc/snapper/configs/root
)
readonly CONFIG_TARGETS=(
/etc/pacman.conf /etc/makepkg.conf /etc/mkinitcpio.conf
/etc/nftables.conf /etc/limine-snapper-sync.conf
/etc/tlp.d/10-archasp.conf /etc/systemd/zram-generator.conf
/etc/snapper/configs/root
)
readonly HELPER_NAMES=(batlimit-set vmware-stack)
readonly SUDOERS_NAMES=(batlimit vmware-stack)
# Le fragment privilégié est fixe : aucun argument n'est interprété comme du
# code. find termine avec 0 et émet un état même si le composant est absent ;
# toute erreur d'inspection fait échouer la commande au lieu de signifier ABSENT.
readonly INSPECT_CODE='
target="$1"
[[ "$target" == /* && "$target" != / ]] || exit 2
rest="${target#/}"
cursor=/
while [[ -n "$rest" ]]; do
name="${rest%%/*}"
[[ -n "$name" && "$name" != . && "$name" != .. ]] || exit 2
if [[ "$rest" == */* ]]; then rest="${rest#*/}"; last=0
else rest=""; last=1; fi
kind="$(find "$cursor" -mindepth 1 -maxdepth 1 -name "$name" -printf "%y\n" -quit)" || exit 3
case "$kind" in
"") printf "ABSENT\n"; exit 0 ;;
l) printf "LINK\n"; exit 0 ;;
d) if (( last )); then printf "INVALID\n"; exit 0; fi ;;
f) if (( last )); then printf "EXISTING\n"; else printf "INVALID\n"; fi; exit 0 ;;
*) printf "INVALID\n"; exit 0 ;;
esac
if [[ "$cursor" == / ]]; then cursor="/$name"; else cursor="$cursor/$name"; fi
done
exit 2
'
usage() {
cat <<'HELP'
Usage : deploy.sh [--dry-run] (--packages|--configs|--tools|--services|--all) ...
--packages Installe les listes de paquets natifs et étrangers (yay requis)
--configs Restaure les configurations système suivies
--tools Installe les helpers root et les règles sudoers
--services Active les services et timers système voulus
--all Exécute les quatre étapes dans cet ordre
--dry-run Affiche un plan statique, sans privilèges ni application
-h, --help Affiche cette aide
Codes : 0 = plan valide, annulation ou application terminée ; 1 = précondition/application
échouée ; 2 = usage invalide ; 130/143 = interruption INT/TERM.
HELP
}
fatal() {
printf '[!] Échec (%s) : %s\n' "$CURRENT_STAGE" "$1" >&2
if (( ${#COMPLETED[@]} )); then
printf '[!] Étapes achevées : %s\n' "${COMPLETED[*]}" >&2
else
printf '[!] Aucune étape achevée.\n' >&2
fi
if (( ${#APPLIED[@]} )); then
printf '[!] Actions déjà appliquées : %s\n' "${APPLIED[*]}" >&2
fi
[[ -z "$CURRENT_ACTION" ]] ||
printf '[!] Action échouée, effets à vérifier : %s\n' "$CURRENT_ACTION" >&2
[[ -z "$BACKUP_DIR" ]] ||
printf '[!] Sauvegardes et marqueurs de cibles absentes : %s\n' "$BACKUP_DIR" >&2
printf '[!] Pas de rollback global : inspecter les changements avant reprise.\n' >&2
exit 1
}
run() {
local label="$1"
shift
CURRENT_ACTION="$label"
"$@" || fatal "$label"
CURRENT_ACTION=''
}
interrupt() {
local signal="$1" status="$2"
trap - INT TERM
printf '[!] Interruption %s (%s), phase : %s\n' "$signal" "$status" "$CURRENT_STAGE" >&2
if (( ${#COMPLETED[@]} )); then
printf '[!] Étapes achevées : %s\n' "${COMPLETED[*]}" >&2
else
printf '[!] Aucune étape achevée.\n' >&2
fi
if (( ${#APPLIED[@]} )); then
printf '[!] Actions confirmées : %s\n' "${APPLIED[*]}" >&2
else
printf '[!] Aucune action confirmée.\n' >&2
fi
[[ -z "$CURRENT_ACTION" ]] ||
printf '[!] Action interrompue, effets à vérifier : %s\n' "$CURRENT_ACTION" >&2
[[ -z "$BACKUP_DIR" ]] ||
printf '[!] Sauvegardes et marqueurs conservés : %s\n' "$BACKUP_DIR" >&2
exit "$status"
}
cleanup() {
local status="$1"
if [[ -n "$TEMP_DIR" ]]; then
# Supprimer uniquement les deux fichiers créés par cette invocation.
rm -f -- "$TEMP_DIR/batlimit" "$TEMP_DIR/vmware-stack" ||
printf '[!] Nettoyage des sudoers temporaires incomplet : %s\n' "$TEMP_DIR" >&2
rmdir -- "$TEMP_DIR" 2>/dev/null || true
fi
exit "$status"
}
trap 'cleanup $?' EXIT
trap 'interrupt INT 130' INT
trap 'interrupt TERM 143' TERM
while (( $# > 0 )); do
case "$1" in
--packages) DO_PACKAGES=1 ;;
--configs) DO_CONFIGS=1 ;;
--tools) DO_TOOLS=1 ;;
--services) DO_SERVICES=1 ;;
--all) DO_PACKAGES=1; DO_CONFIGS=1; DO_TOOLS=1; DO_SERVICES=1 ;;
--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
*) printf 'Option inconnue : %s\n' "$1" >&2; usage; exit 2 ;;
esac
shift
done
if (( DO_PACKAGES + DO_CONFIGS + DO_TOOLS + DO_SERVICES == 0 )); then
usage
exit 2
fi
require_source() {
[[ -f "$1" && -r "$1" && -s "$1" && ! -L "$1" ]] ||
fatal "source obligatoire absente, vide, illisible ou liée : $1"
}
# Une liste présente et vide est volontairement vide. Une substitution de
# processus cacherait une erreur de lecture : lire directement et vérifier.
read_list() {
local file="$1" kind="$2" output_name="$3" line item
local -n output="$output_name"
local -A seen=()
[[ -f "$file" && -r "$file" && ! -L "$file" ]] ||
fatal "liste/manifest absent, illisible ou lié : $file"
while IFS= read -r line || [[ -n "$line" ]]; do
item="${line#"${line%%[![:space:]]*}"}"
item="${item%"${item##*[![:space:]]}"}"
[[ -z "$item" || "$item" == \#* ]] && continue
case "$kind" in
package)
[[ "$item" =~ ^[a-zA-Z0-9@][a-zA-Z0-9@._+-]*$ ]] ||
fatal "nom de paquet invalide dans $file : $item" ;;
service)
[[ "$item" =~ ^[a-zA-Z0-9@_.-]+\.service$ ]] ||
fatal "unité service invalide dans $file : $item" ;;
timer)
[[ "$item" =~ ^[a-zA-Z0-9@_.-]+\.timer$ ]] ||
fatal "unité timer invalide dans $file : $item" ;;
esac
[[ ! -v "seen[$item]" ]] || fatal "doublon dans $file : $item"
seen["$item"]=1
output+=("$item")
done < "$file" || fatal "lecture échouée : $file"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fatal "commande requise absente : $1"
}
read_machine_name() {
[[ -r /etc/hostname ]] || fatal 'nom de machine indisponible : /etc/hostname illisible ou absent'
IFS= read -r MACHINE_NAME < /etc/hostname ||
[[ -n "$MACHINE_NAME" ]] || fatal 'lecture du nom de machine échouée'
[[ "$MACHINE_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] ||
fatal 'nom de machine invalide dans /etc/hostname'
}
check_beneficiary() {
local uid
# Ne jamais utiliser USER/SUDO_USER comme autorité pour sudoers ou AUR.
(( EUID != 0 )) || fatal 'compte root refusé pour cette invocation'
BENEFICIARY="$(id -un)" || fatal 'identité du compte indisponible'
uid="$(id -u "$BENEFICIARY")" || fatal 'compte bénéficiaire inexistant'
[[ "$BENEFICIARY" =~ ^[a-z_][a-z0-9_-]*\$?$ && "$uid" =~ ^[0-9]+$ ]] ||
fatal 'nom de compte bénéficiaire invalide'
(( uid > 0 )) || fatal 'compte bénéficiaire root refusé'
}
check_target_local() {
local parent="${1%/*}"
while [[ "$parent" != / ]]; do
[[ ! -L "$parent" ]] || fatal "répertoire cible lié refusé : $parent"
[[ ! -e "$parent" || -d "$parent" ]] ||
fatal "parent non répertoire refusé : $parent"
parent="${parent%/*}"
[[ -n "$parent" ]] || parent=/
done
[[ ! -L "$1" ]] || fatal "cible liée refusée : $1"
[[ ! -e "$1" || -f "$1" ]] || fatal "cible non régulière refusée : $1"
}
check_unit() {
systemctl cat "$1" >/dev/null 2>&1 || fatal "unité requise absente : $1"
}
check_units() {
local unit
if (( DO_SERVICES )); then
for unit in "${SYSTEM_SERVICES[@]}" "${SYSTEM_TIMERS[@]}"; do
check_unit "$unit"
done
fi
if (( DO_TOOLS || DO_SERVICES )); then
for unit in "${ON_DEMAND_SERVICES[@]}"; do
check_unit "$unit"
done
fi
}
preflight_static() {
local i name
if (( DO_PACKAGES )); then
read_list "$REPO_DIR/pkglist/pacman.txt" package OFFICIAL_PACKAGES
read_list "$REPO_DIR/pkglist/aur.txt" package FOREIGN_PACKAGES
require_command pacman
if (( ${#FOREIGN_PACKAGES[@]} )); then
require_command yay
check_beneficiary
fi
fi
if (( DO_CONFIGS )); then
for i in "${!CONFIG_SOURCES[@]}"; do
require_source "$REPO_DIR/${CONFIG_SOURCES[i]}"
check_target_local "${CONFIG_TARGETS[i]}"
done
if (( ! DO_PACKAGES )); then require_command nft; fi
require_command systemctl
fi
if (( DO_TOOLS )); then
check_beneficiary
for name in "${HELPER_NAMES[@]}"; do
require_source "$REPO_DIR/rootfs/usr/local/sbin/$name"
check_target_local "/usr/local/sbin/$name"
done
for name in "${SUDOERS_NAMES[@]}"; do
require_source "$REPO_DIR/templates/sudoers.d/$name"
grep -Fq '@USER@' "$REPO_DIR/templates/sudoers.d/$name" ||
fatal "placeholder @USER@ absent : $name"
check_target_local "/etc/sudoers.d/$name"
done
require_command visudo
fi
if (( DO_SERVICES )); then
read_list "$REPO_DIR/manifests/system-services.txt" service SYSTEM_SERVICES
read_list "$REPO_DIR/manifests/system-timers.txt" timer SYSTEM_TIMERS
fi
if (( DO_TOOLS || DO_SERVICES )); then
read_list "$REPO_DIR/manifests/on-demand-system-services.txt" service ON_DEMAND_SERVICES
require_command systemctl
fi
if (( ! DO_PACKAGES )); then
check_units
fi
if (( ! DRY_RUN )); then
require_command sudo
require_command install
require_command mktemp
require_command find
read_machine_name
DISPLAY_USER="$(id -un)" || fatal 'identité du compte indisponible'
fi
}
show_plan() {
local i name unit
printf '[PLAN] Aucun changement appliqué ; validations privilégiées non exécutées.\n'
if (( DO_PACKAGES )); then
printf '[PLAN] %s -> pacman -Syu --needed (%s entrées)\n' \
"$REPO_DIR/pkglist/pacman.txt" "${#OFFICIAL_PACKAGES[@]}"
printf '[PLAN] %s -> yay -S --needed (%s entrées étrangères)\n' \
"$REPO_DIR/pkglist/aur.txt" "${#FOREIGN_PACKAGES[@]}"
fi
if (( DO_CONFIGS )); then
printf '[PLAN] Validation nftables privilégiée différée : %s\n' \
"$REPO_DIR/rootfs/etc/nftables.conf"
for i in "${!CONFIG_SOURCES[@]}"; do
printf '[PLAN] %s -> %s\n' \
"$REPO_DIR/${CONFIG_SOURCES[i]}" "${CONFIG_TARGETS[i]}"
done
printf '[PLAN] systemctl daemon-reload ; tlp start si présent.\n'
fi
if (( DO_TOOLS )); then
for name in "${HELPER_NAMES[@]}"; do
printf '[PLAN] %s -> /usr/local/sbin/%s\n' \
"$REPO_DIR/rootfs/usr/local/sbin/$name" "$name"
done
for name in "${SUDOERS_NAMES[@]}"; do
printf '[PLAN] %s -> /etc/sudoers.d/%s (visudo différé, compte %s)\n' \
"$REPO_DIR/templates/sudoers.d/$name" "$name" "$BENEFICIARY"
done
fi
if (( DO_SERVICES )); then
for unit in "${SYSTEM_SERVICES[@]}" "${SYSTEM_TIMERS[@]}"; do
printf '[PLAN] Activer : %s\n' "$unit"
done
fi
if (( DO_TOOLS || DO_SERVICES )); then
for unit in "${ON_DEMAND_SERVICES[@]}"; do
printf '[PLAN] Désactiver et arrêter si nécessaire : %s\n' "$unit"
done
fi
if (( DO_PACKAGES )); then
printf '[PLAN] Unités fournies par les paquets : contrôle différé après installation.\n'
fi
printf '[PLAN] Plan statique valide, déploiement non effectué.\n'
}
preflight_privileged() {
local name
run 'autorisation sudo' sudo -v
if (( DO_CONFIGS )); then
if command -v nft >/dev/null 2>&1; then
run 'validation nftables' sudo nft -c -f "$REPO_DIR/rootfs/etc/nftables.conf"
elif (( ! DO_PACKAGES )); then
fatal 'nft absent avant la phase configurations'
fi
fi
if (( DO_TOOLS )); then
TEMP_DIR="$(mktemp -d)" || fatal 'création du répertoire temporaire sudoers'
for name in "${SUDOERS_NAMES[@]}"; do
sed "s/@USER@/$BENEFICIARY/g" "$REPO_DIR/templates/sudoers.d/$name" \
> "$TEMP_DIR/$name" || fatal "rendu sudoers $name"
run "validation sudoers $name" sudo visudo -cf "$TEMP_DIR/$name"
done
fi
}
# Seul un résultat strict, produit avec un code de succès, décrit la cible.
# Un échec sudo/find, y compris avec une sortie plausible, reste indéterminé.
check_target_privileged() {
local target="$1" state
if state="$(sudo /usr/bin/bash -c "$INSPECT_CODE" -- "$target")"; then
:
else
fatal "contrôle privilégié impossible : $target"
fi
case "$state" in
EXISTING) return 0 ;;
ABSENT) return 1 ;;
LINK) fatal "cible ou parent lié refusé : $target" ;;
INVALID) fatal "cible ou parent non régulier refusé : $target" ;;
*) fatal "réponse de contrôle invalide pour $target" ;;
esac
}
ensure_backup_dir() {
[[ -n "$BACKUP_DIR" ]] && return 0
# mktemp crée atomiquement un répertoire root privé, sans collision
# entre deux invocations commencées dans la même seconde.
BACKUP_DIR="$(sudo mktemp -d /root/arch-system-restore.XXXXXXXX)" ||
fatal 'création du répertoire privé de sauvegarde'
[[ "$BACKUP_DIR" == /root/arch-system-restore.* ]] ||
fatal 'chemin de sauvegarde inattendu'
}
backup_target() {
local target="$1"
local exists=0
if check_target_privileged "$target"; then exists=1; fi
ensure_backup_dir
if (( exists )); then
run "répertoire de sauvegarde $target" sudo mkdir -p \
"$BACKUP_DIR/existing$(dirname "$target")"
run "sauvegarde $target" sudo cp -a -- "$target" \
"$BACKUP_DIR/existing$target"
else
# Un marqueur distinct d'une copie permet de retirer manuellement
# une cible créée par ce déploiement sans inventer un ancien contenu.
run "marqueur de cible absente $target" sudo install -D -m 0600 \
/dev/null "$BACKUP_DIR/absent$target"
fi
}
install_target() {
local source="$1" target="$2" mode="$3"
backup_target "$target"
# Réexaminer la cible après la copie et avant le remplacement ; un
# changement concurrent ne doit pas transformer la destination en lien.
if check_target_privileged "$target"; then :; fi
run "installation $target" sudo install -D -m "$mode" "$source" "$target"
APPLIED+=("installation:$target")
}
unit_state() {
local unit="$1" property="$2" result
if [[ "$property" == enabled ]]; then
result="$(systemctl is-enabled "$unit" 2>/dev/null)" || :
else
result="$(systemctl is-active "$unit" 2>/dev/null)" || :
fi
[[ -n "$result" ]] || fatal "état systemd indéterminé : $unit"
printf '%s' "$result"
}
disable_on_demand() {
local unit enabled active
for unit in "${ON_DEMAND_SERVICES[@]}"; do
check_unit "$unit"
enabled="$(unit_state "$unit" enabled)"
active="$(unit_state "$unit" active)"
if [[ "$enabled" == disabled && "$active" == inactive ]]; then
printf '[*] Déjà désactivé/inactif : %s\n' "$unit"
continue
fi
[[ "$enabled" =~ ^(enabled|enabled-runtime|disabled|masked)$ &&
"$active" =~ ^(active|activating|inactive|failed)$ ]] ||
fatal "état systemd non pris en charge : $unit ($enabled/$active)"
run "désactivation $unit" sudo systemctl disable --now "$unit"
APPLIED+=("désactivation:$unit")
done
}
enable_units() {
local unit
for unit in "${SYSTEM_SERVICES[@]}" "${SYSTEM_TIMERS[@]}"; do
check_unit "$unit"
run "activation $unit" sudo systemctl enable "$unit"
APPLIED+=("activation:$unit")
done
}
preflight_static
if (( DRY_RUN )); then
show_plan
exit 0
fi
read -rp "Déployer arch-system sur ${MACHINE_NAME} pour ${DISPLAY_USER} ? [y/N] " confirm ||
fatal 'confirmation indisponible'
[[ "$confirm" =~ ^[yY]$ ]] || { printf '[*] Déploiement annulé.\n'; exit 0; }
# Les validations privilégiées précèdent tout changement durable ; les unités
# fournies par les paquets demandés restent nécessairement vérifiées après eux.
preflight_privileged
if (( DO_PACKAGES )); then
CURRENT_STAGE='paquets'
if (( ${#OFFICIAL_PACKAGES[@]} )); then
run 'installation des paquets natifs' sudo pacman -Syu --needed \
"${OFFICIAL_PACKAGES[@]}"
APPLIED+=(paquets-natifs)
else
printf '[*] Liste native présente mais vide.\n'
fi
if (( ${#FOREIGN_PACKAGES[@]} )); then
run 'installation des paquets étrangers' yay -S --needed \
"${FOREIGN_PACKAGES[@]}"
APPLIED+=(paquets-étrangers)
else
printf '[*] Liste étrangère présente mais vide.\n'
fi
COMPLETED+=(paquets)
if (( DO_CONFIGS )) && ! command -v nft >/dev/null 2>&1; then
fatal 'nft reste absent après installation des paquets'
fi
if (( DO_CONFIGS )); then
run 'validation nftables différée' sudo nft -c -f \
"$REPO_DIR/rootfs/etc/nftables.conf"
fi
if (( DO_TOOLS || DO_SERVICES )); then
CURRENT_STAGE='contrôle différé des unités'
check_units
fi
fi
if (( DO_CONFIGS )); then
CURRENT_STAGE='configurations'
for i in "${!CONFIG_SOURCES[@]}"; do
install_target "$REPO_DIR/${CONFIG_SOURCES[i]}" "${CONFIG_TARGETS[i]}" 0644
done
run 'rechargement systemd' sudo systemctl daemon-reload
APPLIED+=(rechargement-systemd)
if command -v tlp >/dev/null 2>&1; then
run 'démarrage TLP' sudo tlp start
APPLIED+=(démarrage-tlp)
fi
COMPLETED+=(configurations)
printf '[!] fstab, Limine et Secure Boot restent à finaliser manuellement.\n'
fi
if (( DO_TOOLS )); then
CURRENT_STAGE='outils root'
for name in "${HELPER_NAMES[@]}"; do
install_target "$REPO_DIR/rootfs/usr/local/sbin/$name" \
"/usr/local/sbin/$name" 0755
done
for name in "${SUDOERS_NAMES[@]}"; do
install_target "$TEMP_DIR/$name" "/etc/sudoers.d/$name" 0440
done
disable_on_demand
COMPLETED+=(outils)
fi
if (( DO_SERVICES )); then
CURRENT_STAGE='services système'
enable_units
disable_on_demand
COMPLETED+=(services)
fi
printf '[+] Déploiement sélectionné terminé : %s\n' "${COMPLETED[*]}"
[[ -z "$BACKUP_DIR" ]] || printf '[+] Sauvegardes et marqueurs : %s\n' "$BACKUP_DIR"
printf '[!] Aucune transaction globale ni rollback automatique.\n'
printf '[!] Vérifier fstab, Limine, mkinitcpio, Snapper, nftables et sbctl avant reboot.\n'