#!/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=("${@}") 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[@]}"