zfs-pacman-hook/pacman-zfs-snapshot.sh

413 lines
16 KiB
Bash
Executable File

#!/bin/bash
declare -a pkgs
while read pkg; do
pkgs+=("${pkg}")
done
declare conf_file
conf_file='/etc/pacman-zfs-snapshot.conf'
declare important_names snaps_trivial_keep snaps_important_keep snaps_trivial_suffix snaps_important_suffix
if [[ -r "${conf_file}" ]]; then
source "${conf_file}"
fi
# User-defined
do_dry_run="${do_dry_run:-false}"
important_names="${important_names:-linux|systemd|zfs-(dkms|utils)}"
snaps_trivial_keep="${snaps_trivial_keep:-15}"
snaps_important_keep="${snaps_important_keep:-5}"
snaps_trivial_suffix="${snaps_trivial_suffix:-trv}"
snaps_important_suffix="${snaps_important_suffix:-imp}"
pkgs_list_max_length="${pkgs_list_max_length:-30}"
snap_only_local_datasets="${snap_only_local_datasets:-true}"
snap_field_separator="${snap_field_separator:-_}"
snap_name_prefix="${snap_name_prefix:-pacman}"
snap_date_format="${snap_date_format:-%F-%H%M}"
snap_timezone="${snap_timezone:-Etc/UTC}"
snap_op_installation_suffix="${snap_op_installation_suffix:-inst}"
snap_op_remove_suffix="${snap_op_remove_suffix:-rmvl}"
snap_op_upgrade_suffix="${snap_op_upgrade_suffix:-upgr}"
# Internal
declare zfs_prop pkg_separator max_zfs_snapshot_name_length color_reset color_lyellow color_red
zfs_prop='space.quico:auto-snapshot'
pkg_separator=':'
max_zfs_snapshot_name_length='255'
color_reset='\e[0m'
color_lyellow='\e[93m'
color_red='\e[31m'
declare operation conf_op_suffix
operation="${1}"
case "${operation}" in
install)
conf_op_suffix="${snap_op_installation_suffix}"
;;
remove)
conf_op_suffix="${snap_op_remove_suffix}"
;;
upgrade)
conf_op_suffix="${snap_op_upgrade_suffix}"
;;
esac
function pprint () {
local style msg exit_code
style="${1:?}"
msg="${2:?}"
exit_code="${3}"
case "${style}" in
warn)
printf -- "${color_lyellow}"'[WARN]'"${color_reset}"' %s\n' "${msg}"
;;
err)
printf -- "${color_red}"'[ERR] '"${color_reset}"' %s\n' "${msg}"
;;
info)
printf -- '[INFO] %s\n' "${msg}"
;;
esac
[[ "${exit_code}" ]] && exit "${exit_code}"
}
function split_pkgs_by_importance () {
local pkgs_in_transaction
pkgs_in_transaction=("${@}")
for pkg in "${pkgs_in_transaction[@]}"; do
if grep -Piq -- '^('"${important_names}"')$' <<<"${pkg}"; then
important_pkgs_in_transaction+=("${pkg}")
else
trivial_pkgs_in_transaction+=("${pkg}")
fi
done
}
function set_severity () {
if [[ "${#important_pkgs_in_transaction[@]}" -ge '1' ]]; then
severity="${snaps_important_suffix}"
else
severity="${snaps_trivial_suffix}"
fi
}
function get_globally_snappable_datasets () {
local datasets_list
# For all datasets show their "${zfs_prop}" property; only print dataset
# name in column 1 and property value in column 2. In awk limit this
# list to datasets where tab-delimited column 2 has exact string
# '^true$' then further limit output by eliminating snapshots from list,
# i.e. dataset names that contain an '@' character.
datasets_list="$(zfs get -H -o 'name,value' "${zfs_prop}" | \
awk -F'\t' '{if($2 ~ /^true$/ && $1 !~ /@/) print $1}')"
while IFS= read -u10 -r dataset; do
globally_snappable_datasets+=("${dataset}")
done 10<<<"${datasets_list}"
}
function get_local_snappable_datasets () {
local datasets_list
datasets_list="$(findmnt --json --list --output 'fstype,source,target' | \
jq --raw-output '.[][] | select(.fstype=="zfs") | .source')"
while IFS= read -u10 -r dataset; do
local_snappable_datasets+=("${dataset}")
done 10<<<"${datasets_list}"
}
function trim_globally_snappable_datasets () {
for global_dataset in "${globally_snappable_datasets[@]}"; do
for local_dataset in "${local_snappable_datasets[@]}"; do
if grep -Piq -- '^'"${local_dataset}"'$' <<<"${global_dataset}"; then
snappable_datasets+=("${global_dataset}")
fi
done
done
}
function write_pkg_list_oneline () {
if [[ "${severity}" == "${snaps_important_suffix}" ]]; then
for pkg in "${important_pkgs_in_transaction[@]}"; do
if [[ "${unabridged_pkg_list_oneline}" ]]; then
unabridged_pkg_list_oneline="${unabridged_pkg_list_oneline}${pkg_separator}${pkg}"
else
unabridged_pkg_list_oneline="${pkg}"
fi
done
fi
if [[ "${#trivial_pkgs_in_transaction[@]}" -ge '1' ]]; then
for pkg in "${trivial_pkgs_in_transaction[@]}"; do
if [[ "${unabridged_pkg_list_oneline}" ]]; then
unabridged_pkg_list_oneline="${unabridged_pkg_list_oneline}${pkg_separator}${pkg}"
else
unabridged_pkg_list_oneline="${pkg}"
fi
done
fi
}
function trim_single_remaining_package_name () {
local pkg_name
pkg_name="${shorter_pkg_list}"
case 1 in
# Trim to 1 to 3 characters, no trailing ellipsis (...)
$(( 1<=pkgs_list_max_length && pkgs_list_max_length<=3 )))
pkg_name="${pkg_name::${pkgs_list_max_length}}"
;;
# Show as many pkg name characters as we can while also
# fitting an ellipsis into the name (...) to indicate
# that we've cut the pkg name off at the end.
$(( pkgs_list_max_length>=4 )))
pkg_name="${pkg_name::$(( pkgs_list_max_length - 3 ))}"'...'
;;
esac
shorter_pkg_list="${pkg_name}"
}
function trim_pkg_list_oneline () {
local available_pkg_list_length
available_pkg_list_length="${1}"
if [[ "${available_pkg_list_length}" -lt "${pkgs_list_max_length}" ]]; then
# If we have fewer characters available before hitting the
# ZFS internal maximum snapshot name length than the user
# wants limit package list length.
pkgs_list_max_length="${available_pkg_list_length}"
fi
local shorter_pkg_list
if [[ "${pkgs_list_max_length}" -le '0' ]]; then
# User wants zero characters of pkg names in snapshot name,
# no need to even find an appropriate pkg name string. Just
# set to empty string and we're done here.
shorter_pkg_list=''
else
shorter_pkg_list="${unabridged_pkg_list_oneline}"
while [[ "${#shorter_pkg_list}" -gt "${pkgs_list_max_length}" ]]; do
shorter_pkg_list="${shorter_pkg_list%${pkg_separator}*}"
if ! grep -Piq "${pkg_separator}" <<<"${shorter_pkg_list}"; then
# Only one package remains in package list, no need to continue
break
fi
done
# If pkg name is still too long trim it. If there's enough
# space for an ellipsis (...) we add that to indicate we've
# trimmed the name, otherwise we just take however many
# characters of the pkg name we can get.
if [[ "${#shorter_pkg_list}" -gt "${pkgs_list_max_length}" ]]; then
trim_single_remaining_package_name
fi
fi
trimmed_pkg_list_oneline="${shorter_pkg_list}"
}
function test_snap_names_for_validity () {
local snap_counter max_dataset_name_length trimmed_pkg_list_oneline dataset_name_no_pkgs dataset_name_with_pkgs
snap_counter="${1}"
max_dataset_name_length='0'
for dataset in "${snappable_datasets[@]}"; do
# Begin building snapshot name
dataset_name_no_pkgs="${dataset}"'@'"${snap_name_prefix}${snap_field_separator}${date_string}"
# Append counter
dataset_name_no_pkgs="${dataset_name_no_pkgs}${snap_field_separator}${snap_counter}"
# Append operation, severity and packages fields
dataset_name_no_pkgs="${dataset_name_no_pkgs}${snap_field_separator}"'op:'"${conf_op_suffix}${snap_field_separator}"'sev:'"${severity}"
# Update the longest snapshot name seen so far. We add an automatic
# +6 to string length (or more exactly ${#snap_field_separator}+5)
# to account for the fact that by default the dataset will end in
# the separator string "${snap_field_separator}" plus 'pkgs:' for a
# total of 6 additional characters. If these additional characters
# cause us to reach or go over the ZFS dataset name length limit
# there's no point in attempting to add package names to snapshots.
# We calculate as if these additional characters existed and we add
# dataset names to our planned_snaps array as if they don't.
if [[ "$(( ${#dataset_name_no_pkgs}+${#snap_field_separator}+5 ))" -gt "${max_dataset_name_length}" ]]; then
max_dataset_name_length="$(( ${#dataset_name_no_pkgs}+${#snap_field_separator}+5 ))"
fi
planned_snaps+=("${dataset_name_no_pkgs}")
done
# Abort if this is longer than what ZFS allows
if [[ "${max_dataset_name_length}" -gt "${max_zfs_snapshot_name_length}" ]]; then
pprint 'err' 'Snapshot name would exceed ZFS '"${max_zfs_snapshot_name_length}"' chars limit. Aborting ...' '1'
fi
if [[ "${max_dataset_name_length}" -eq "${max_zfs_snapshot_name_length}" ]]; then
for planned_snap in "${planned_snaps[@]}"; do
if grep -Piq -- '^'"${planned_snap}"'$' <<<"${existing_snaps}"; then
# This snapshot name already exists. Unset array and break.
# Try again with next higher counter suffix.
unset planned_snaps[@]
break
fi
done
# If planned_snaps array still has members we take the snapshot
# names already generated. If not we return without array in which
# case this function will run again with the snapshot counter
# incremented by one. Maximum length seen across all snapshot names
# is exactly the ZFS snapshot character limit. We won't be able to
# add packages to snapshot names but they will all fit perfectly.
# This is good enough.
return
else
# We have enough room to add package names.
local available_pkg_list_length
available_pkg_list_length="${pkgs_list_max_length}"
if [[ "${max_dataset_name_length}" -gt $(( max_zfs_snapshot_name_length - pkgs_list_max_length )) ]]; then
available_pkg_list_length="$(( max_zfs_snapshot_name_length - max_dataset_name_length ))"
fi
trim_pkg_list_oneline "${available_pkg_list_length}"
for planned_snap_id in "${!planned_snaps[@]}"; do
planned_snaps["${planned_snap_id}"]="${planned_snaps[${planned_snap_id}]}${snap_field_separator}"'pkgs:'"${trimmed_pkg_list_oneline}"
if grep -Piq -- '^'"${planned_snaps[${planned_snap_id}]}"'$' <<<"${existing_snaps}"; then
# This snapshot name already exists. Unset array and break.
# Try again with next higher counter suffix.
unset planned_snaps[@]
break
fi
done
fi
}
function generate_snap_names () {
local snap_counter existing_snaps
snap_counter='0'
existing_snaps="$(zfs list -t all -oname -H)"
until [[ "${#planned_snaps[@]}" -gt '0' ]]; do
snap_counter="$(( snap_counter+1 ))"
test_snap_names_for_validity "${snap_counter}"
done
}
function do_snaps () {
local snap_return_code
if [[ "${do_dry_run}" == 'true' ]]; then
pprint 'info' 'Dry-run, pretending to atomically do ZFS snapshot:'
for planned_snap in "${planned_snaps[@]}"; do
pprint 'info' ' '"${planned_snap}"
done
else
zfs snapshot "${planned_snaps[@]}"
snap_return_code="${?}"
if [[ "${snap_return_code}" -eq '0' ]]; then
successfully_snapped_datasets=("${snappable_datasets[@]}")
pprint 'info' 'ZFS snapshot atomically done:'
for planned_snap in "${planned_snaps[@]}"; do
pprint 'info' ' '"${planned_snap}"
done
else
pprint 'warn' 'ZFS snapshot failed:'
for planned_snap in "${planned_snaps[@]}"; do
pprint 'warn' ' '"${planned_snap}"
done
fi
fi
}
function get_snaps_in_cur_sev () {
local dataset_to_query
dataset_to_query="${1:?}"
snap_list="$(zfs list -H -o 'name' -t snapshot "${dataset_to_query}")"
snaps_done_by_us="$(grep -Pi -- '@'"${snap_name_prefix}${snap_field_separator}" <<<"${snap_list}")"
snaps_in_cur_sev="$(grep -Pi -- "${snap_field_separator}"'sev:'"${severity}${snap_field_separator}" <<<"${snaps_done_by_us}")"
printf -- '%s\n' "${snaps_in_cur_sev}"
}
function do_retention () {
local snap_list snaps_done_by_us snaps_in_cur_sev snaps_limit oldest_snap snap_return_code
local -a destroyed_snaps failed_to_destroy_snaps
if [[ "${do_dry_run}" == 'true' ]]; then
pprint 'info' 'Dry-run, skipping potential ZFS destroy operations ...'
else
for successfully_snapped_dataset in "${successfully_snapped_datasets[@]}"; do
snaps_in_cur_sev="$(get_snaps_in_cur_sev "${successfully_snapped_dataset}")"
if [[ "${severity}" == "${snaps_important_suffix}" ]]; then
snaps_limit="${snaps_important_keep}"
else
snaps_limit="${snaps_trivial_keep}"
fi
while [[ "$(get_snaps_in_cur_sev "${successfully_snapped_dataset}" | wc -l)" -gt "${snaps_limit}" ]]; do
oldest_snap="$(get_snaps_in_cur_sev "${successfully_snapped_dataset}" | head -n1)"
zfs destroy "${oldest_snap}"
snap_return_code="${?}"
if [[ "${snap_return_code}" -eq '0' ]]; then
destroyed_snaps+=("${oldest_snap}")
else
failed_to_destroy_snaps+=("${oldest_snap}")
fi
done
if [[ "${#destroyed_snaps[@]}" -gt '0' ]]; then
pprint 'info' 'Oldest ZFS snapshot'"$([[ "${#failed_to_destroy_snaps[@]}" -gt '1' ]] && printf -- '%s' 's')"' in chain '"'"'sev:'"${severity}"''"'"' destroyed:'
for destroyed_snap in "${destroyed_snaps[@]}"; do
pprint 'info' ' '"${destroyed_snap}"
done
fi
if [[ "${#failed_to_destroy_snaps[@]}" -gt '0' ]]; then
pprint 'warn' 'Failed to prune ZFS snapshot'"$([[ "${#failed_to_destroy_snaps[@]}" -gt '1' ]] && printf -- '%s' 's')"' in chain '"'"'sev:'"${severity}"''"'"':'
for failed_to_destroy_snap in "${failed_to_destroy_snaps[@]}"; do
pprint 'warn' ' '"${failed_to_destroy_snap}"
done
fi
done
fi
return 0
}
function main () {
local pkgs_in_transaction
pkgs_in_transaction=("${@}")
# Replace characters that are valid as Arch Linux package names but invalid as ZFS dataset names with something ZFS
# doesn't mind. Replace at characters ('@') indiscriminately with one dot each ('.'), replace plus characters ('+')
# with one underscore each ('_').
pkgs_in_transaction=("${pkgs_in_transaction[@]//+/_}")
pkgs_in_transaction=("${pkgs_in_transaction[@]//@/.}")
local -a important_pkgs_in_transaction trivial_pkgs_in_transaction
split_pkgs_by_importance "${pkgs_in_transaction[@]}"
local severity
set_severity
local -a globally_snappable_datasets
get_globally_snappable_datasets
local -a snappable_datasets
if [[ "${snap_only_local_datasets}" == 'true' ]]; then
local local_snappable_datasets
get_local_snappable_datasets
trim_globally_snappable_datasets
if [[ "${#snappable_datasets[@]}" -eq '0' ]]; then
pprint 'info' 'ZFS snapshot skipped, no local (= currently mounted) dataset has'
pprint 'info' 'property '"'"''"${zfs_prop}"''"'"' set to '"'"'true'"'"'. At the same'
pprint 'info' 'time option '"'"'snap_only_local_datasets'"'"' equals '"'"'true'"'"' so'
pprint 'info' 'we must only snapshot local datasets. Nothing to do here while'
pprint 'info' 'none of them have '"'"''"${zfs_prop}"''"'"' set to '"'"'true'"'"'.' '0'
fi
else
snappable_datasets=("${globally_snappable_datasets}")
if [[ "${#snappable_datasets[@]}" -eq '0' ]]; then
pprint 'info' 'ZFS snapshot skipped, no dataset has property '"'"''"${zfs_prop}"''"'"' set to '"'"'true'"'"'.' '0'
fi
fi
local unabridged_pkg_list_oneline
write_pkg_list_oneline
local date_string
local -a planned_snaps
date_string="$($([[ "${snap_timezone}" ]] && printf -- 'export TZ='"${snap_timezone}"); date +"${snap_date_format}")"
generate_snap_names
local -a successfully_snapped_datasets
do_snaps
do_retention
}
main "${pkgs[@]}"