413 lines
16 KiB
Bash
Executable File
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[@]}"
|