From 942128c89f30718cc8203d895fcdad1800d7f6f5 Mon Sep 17 00:00:00 2001 From: hygienic-books Date: Wed, 16 Sep 2026 09:05:17 +0200 Subject: [PATCH] feat(apt): add package-aware snapshot helper Parse the APT package action protocol and classify records by operation. Use the package list to determine important and trivial snapshot chains, then generate collision-resistant names containing shortened package data. Select ZFS datasets through the configured property or explicit roots. Create recursive snapshots atomically, support dry-run output, and prune old snapshots from the matching retention chain after success. Preserve the original warning-oriented behavior by reporting snapshot and retention failures without aborting the package operation. --- apt-zfs-snapshot.sh | 221 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 apt-zfs-snapshot.sh diff --git a/apt-zfs-snapshot.sh b/apt-zfs-snapshot.sh new file mode 100644 index 0000000..b33d028 --- /dev/null +++ b/apt-zfs-snapshot.sh @@ -0,0 +1,221 @@ +#!/bin/bash + +set -o pipefail + +declare -r config_file='/etc/apt-zfs-snapshot.conf' +declare -r zfs_property='space.quico:auto-snapshot' +declare -r zfs_property_value='true' +declare -r zfs_max_name_length=255 + +if [[ -r "${config_file}" ]]; then + # shellcheck disable=SC1090 + source "${config_file}" +fi + +dry_run="${dry_run:-false}" +important_names="${important_names:-linux-image(-.*)?|linux-headers(-.*)?|systemd|zfs-(dkms|utils)}" +snapshots_trivial_keep="${snapshots_trivial_keep:-25}" +snapshots_important_keep="${snapshots_important_keep:-10}" +trivial_suffix="${trivial_suffix:-trv}" +important_suffix="${important_suffix:-imp}" +packages_max_length="${packages_max_length:-30}" +snap_only_local_datasets="${snap_only_local_datasets:-true}" +snapshot_roots="${snapshot_roots:-}" +field_separator="${field_separator:-_}" +snapshot_prefix="${snapshot_prefix:-apt}" +date_format="${date_format:-%F-%H%M}" +timezone="${timezone:-Etc/UTC}" +install_suffix="${install_suffix:-inst}" +remove_suffix="${remove_suffix:-rmvl}" +upgrade_suffix="${upgrade_suffix:-upgr}" +downgrade_suffix="${downgrade_suffix:-down}" +mixed_suffix="${mixed_suffix:-mixd}" +package_separator="${package_separator:-:}" + +declare -a packages operations + +print_msg() { + local level="$1" message="$2" + printf '[%s] %s\n' "${level}" "${message}" >&2 +} + +add_package() { + local package="$1" operation="$2" + [[ -n "${package}" ]] || return 0 + packages+=("${package//+/_}") + operations+=("${operation}") +} + +parse_apt_input() { + local fd="${APT_HOOK_INFO_FD:-0}" line package old_version direction new_version action + local protocol='1' in_records='false' + + while IFS= read -r line <&"${fd}"; do + if [[ "${line}" == VERSION\ * ]]; then + protocol="${line#VERSION }" + continue + fi + if [[ "${protocol}" == '1' && -n "${line}" ]]; then + package="${line##*/}" + package="${package%.deb}" + package="${package%_*_*}" + add_package "${package}" 'install' + continue + fi + if [[ "${in_records}" != 'true' ]]; then + [[ -z "${line}" ]] && in_records='true' + continue + fi + + if [[ "${protocol}" == '1' ]]; then + package="${line##*/}" + package="${package%.deb}" + package="${package%_*_*}" + add_package "${package}" 'install' + continue + fi + + read -r package old_version _old_arch direction new_version _new_arch _multiarch action <<<"${line}" + [[ -n "${package}" ]] || continue + if [[ "${action}" == '**REMOVE**' ]]; then + add_package "${package}" 'remove' + elif [[ "${direction}" == '>' ]]; then + add_package "${package}" 'downgrade' + elif [[ "${old_version}" == '-' ]]; then + add_package "${package}" 'install' + elif [[ "${direction}" == '<' ]]; then + add_package "${package}" 'upgrade' + fi + done +} + +get_datasets() { + local dataset value + if [[ -n "${snapshot_roots}" ]]; then + read -r -a snappable_datasets <<<"${snapshot_roots}" + return 0 + fi + + while IFS=$'\t' read -r dataset value; do + [[ "${value}" == "${zfs_property_value}" && "${dataset}" != *@* ]] || continue + if [[ "${snap_only_local_datasets}" != 'true' ]] || findmnt -rn -t zfs -o SOURCE | grep -Fxq -- "${dataset}"; then + snappable_datasets+=("${dataset}") + fi + done < <(zfs get -H -o name,value "${zfs_property}") +} + +operation_suffix() { + local -A seen=() + local operation + for operation in "${operations[@]}"; do seen["${operation}"]=1; done + if (( ${#seen[@]} > 1 )); then printf '%s' "${mixed_suffix}"; return; fi + case "${operations[0]:-upgrade}" in + install) printf '%s' "${install_suffix}" ;; + remove) printf '%s' "${remove_suffix}" ;; + downgrade) printf '%s' "${downgrade_suffix}" ;; + *) printf '%s' "${upgrade_suffix}" ;; + esac +} + +severity_and_packages() { + local package important='false' + local -a important_packages trivial_packages + for package in "${packages[@]}"; do + if grep -Piq -- "^(${important_names})$" <<<"${package}"; then + important_packages+=("${package}") + important='true' + else + trivial_packages+=("${package}") + fi + done + if [[ "${important}" == 'true' ]]; then + severity="${important_suffix}" + package_list="$(IFS="${package_separator}"; printf '%s' "${important_packages[*]}")" + [[ -n "${trivial_packages[*]}" ]] && package_list+="${package_separator}$(IFS="${package_separator}"; printf '%s' "${trivial_packages[*]}")" + else + severity="${trivial_suffix}" + package_list="$(IFS="${package_separator}"; printf '%s' "${trivial_packages[*]}")" + fi +} + +shorten_packages() { + local value="${package_list}" limit="${packages_max_length}" dataset fixed_length available + for dataset in "${snappable_datasets[@]}"; do + fixed_length="${#dataset}" + fixed_length=$((fixed_length + 1 + ${#snapshot_prefix} + ${#field_separator} + ${#date_string} + ${#field_separator} + 10 + ${#field_separator} + 4 + ${#severity} + ${#field_separator} + 5)) + available=$((zfs_max_name_length - fixed_length)) + (( available < limit )) && limit="${available}" + done + (( limit < 0 )) && limit=0 + (( limit > 0 )) || { package_list=''; return; } + while (( ${#value} > limit )) && [[ "${value}" == *"${package_separator}"* ]]; do + value="${value%"${package_separator}"*}" + done + if (( ${#value} > limit )); then + if (( limit <= 3 )); then value="${value:0:limit}"; else value="${value:0:limit-3}..."; fi + fi + package_list="${value}" +} + +existing_snapshot() { zfs list -H -t snapshot -o name | grep -Fxq -- "$1"; } + +make_snapshot_name() { + local counter="$1" dataset base + names=() + for dataset in "${snappable_datasets[@]}"; do + base="${dataset}@${snapshot_prefix}${field_separator}${date_string}${field_separator}${counter}${field_separator}op:$(operation_suffix)${field_separator}sev:${severity}${field_separator}pkgs:${package_list}" + (( ${#base} <= zfs_max_name_length )) || { print_msg ERR "Snapshot name exceeds ${zfs_max_name_length} characters: ${base}"; return 2; } + names+=("${base}") + existing_snapshot "${base}" && return 1 + done +} + +prune_snapshots() { + local dataset limit name count + [[ "${dry_run}" == 'true' ]] && return 0 + limit="${snapshots_trivial_keep}" + [[ "${severity}" == "${important_suffix}" ]] && limit="${snapshots_important_keep}" + for dataset in "${snappable_datasets[@]}"; do + # Query only the root dataset. Destroying its recursive snapshot also + # removes the matching descendant snapshots as one snapshot tree. + mapfile -t snapshots < <(zfs list -H -t snapshot -o name -s creation "${dataset}" | grep -F "${dataset}@${snapshot_prefix}${field_separator}" | grep -F "${field_separator}sev:${severity}${field_separator}") + count="${#snapshots[@]}" + while (( count > limit )); do + name="${snapshots[0]}" + zfs destroy -r "${name}" || { print_msg WARN "Failed to destroy ${name}"; break; } + snapshots=("${snapshots[@]:1}") + ((count--)) + print_msg INFO "Destroyed old snapshot ${name}" + done + done +} + +main() { + local counter=0 + parse_apt_input + (( ${#packages[@]} > 0 )) || { print_msg INFO 'No package actions received; skipping snapshot'; return 0; } + get_datasets + (( ${#snappable_datasets[@]} > 0 )) || { print_msg INFO "No eligible ZFS datasets; skipping snapshot"; return 0; } + date_string="$(TZ="${timezone}" date +"${date_format}")" + severity_and_packages + shorten_packages + while :; do + make_snapshot_name "$((++counter))" + case "$?" in + 0) break ;; + 1) ;; + *) return 1 ;; + esac + done + if [[ "${dry_run}" == 'true' ]]; then + printf '[INFO] Dry-run, would create:\n' >&2 + printf ' %s\n' "${names[@]}" >&2 + return 0 + fi + zfs snapshot -r "${names[@]}" || { print_msg WARN 'ZFS snapshot failed'; return 0; } + printf '[INFO] Created ZFS snapshots:\n' >&2 + printf ' %s\n' "${names[@]}" >&2 + prune_snapshots +} + +main