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.
222 lines
8.1 KiB
Bash
222 lines
8.1 KiB
Bash
#!/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
|