Compare commits

53 Commits

Author SHA1 Message Date
df8764c739 docs(conf): Explain dry run behavior correctly (#1) 2023-03-07 01:12:48 +01:00
47bb8cbd30 docs(conf): Add comment per setting (#1) 2023-03-07 01:10:38 +01:00
9e8772fad5 refactor(hook): Generalize hook name (#1) 2023-03-07 01:10:06 +01:00
2e0bb42372 fix(script): Output snapshots list with trailing line break so that 'wc -l' can better count them (#1) 2023-03-07 01:09:36 +01:00
12283e49c2 refactor(script): On ZFS operations catch and use return code (#1) 2023-03-07 01:08:55 +01:00
c899d26134 fix(script): Use correct operation suffix in snapshot name (#1) 2023-03-07 01:08:20 +01:00
7fc828fb14 fix(conf): Get default vars consistent with config file (#1) 2023-03-07 01:07:39 +01:00
7a494cb65a fix(script): Correctly assign default values if unset or empty (#1) 2023-03-07 01:02:43 +01:00
2fadc427c5 refactor(script): Define color codes just once (#1) 2023-03-07 01:01:55 +01:00
ce93c8558e refactor(script): Get variables first then define an operation suffix (#1) 2023-03-07 01:01:20 +01:00
4553bee760 fix(script): Run while loop through different file descriptor for easier trap-debugging (#1) 2023-03-07 00:05:47 +01:00
39490c3d7a refactor(hook): Allow pkg list of up to 30 chars length (#1) 2023-03-07 00:04:34 +01:00
776480a9a3 refactor(script): Reuse the same pkg name separator in a var (#1) 2023-03-07 00:04:00 +01:00
c9fcdb5b29 refactor(script): Differentiate between user-defined and internal vars (#1) 2023-03-07 00:03:01 +01:00
3277e7a31a docs(script): pkg name regex is encapsulated in parentheses (#1) 2023-03-07 00:01:45 +01:00
9a92e99c6a docs(hook): We use multiple hook files (#1) 2023-03-07 00:00:18 +01:00
ed15631ad2 fix(script): Fix var reference (#1) 2023-03-06 03:15:48 +01:00
85a7fb66be fix(script): Fix var reference (#1) 2023-03-06 03:10:01 +01:00
41c30d5971 fix(script): Don't reinitialize max_dataset_name_length (#1) 2023-03-06 03:06:19 +01:00
9437dea225 fix(script): Fix date format (#1) 2023-03-06 03:02:02 +01:00
35f572fe97 fix(script): fix if clause (#1) 2023-03-06 02:55:21 +01:00
70145d5897 feat(script): Add dry-run functionality (#1) 2023-03-06 02:47:11 +01:00
05a9f22b01 feat(script): Implement retention for old snapshots (#1) 2023-03-06 02:40:16 +01:00
f5956063a9 feat(script): Identify snaps in current chain (#1) 2023-03-06 02:39:51 +01:00
12207a1376 feat(script): Add successful snapshot datasets to array (#1) 2023-03-06 02:39:21 +01:00
2a256272d8 fix(conf): Use vars for severity names (#1) 2023-03-06 02:38:39 +01:00
ba2c7040a8 fix(conf): Keep 15 trivial snapshots (#1) 2023-03-06 02:38:17 +01:00
6f92693c0f feat(script): Do snapshots and talk about it (#1) 2023-03-06 02:15:52 +01:00
aa60a863d8 refactor(script): Reuse date_string instead of regenerating it (#1) 2023-03-06 02:15:28 +01:00
0222cc9649 refactor(script): Name normal msgs "info" (#1) 2023-03-06 02:14:47 +01:00
e7c913c4db feat(script): Limit length of package name list (#1) 2023-03-06 02:07:54 +01:00
7747e9bdc3 feat(script): Print warning and exit gracefully if snapshot name would be too long (#1) 2023-03-06 02:04:37 +01:00
f8592c215a feat(script): Add colored print (#1) 2023-03-06 02:03:54 +01:00
f5bb694768 feat(conf): Find how long a dataset name will be (#1) 2023-03-06 01:24:13 +01:00
8e8be69f12 feat(conf): Make snapshot name components configurable (#1) 2023-03-06 01:21:56 +01:00
60b3a92abb feat(script): Allow snapping only local datasets (#1) 2023-03-06 00:57:02 +01:00
673fb15b46 refactor(script): Explain pacman-zfs-snapshot.conf last (#1) 2023-03-06 00:40:08 +01:00
36cd7ea16d feat(script): Introduce dry-run option (#1) 2023-03-06 00:39:18 +01:00
8fbf16bda0 docs(script): Explain space.quico:auto-snapshot ZFS property (#1) 2023-03-06 00:36:20 +01:00
7427d8477f feat(script): List all snappable datasets (#1) 2023-03-06 00:32:58 +01:00
8dca85c98f refactor(script): Local instead of global var (#1) 2023-03-06 00:16:00 +01:00
b1249d6a40 feat(script): Generate list of affected pkgs on one line (#1) 2023-03-06 00:15:23 +01:00
188e3481af feat(script): Define severity based on affected pkgs (#1) 2023-03-06 00:14:47 +01:00
b128b5f2ae feat(script): Catch install, remove, upgrade operation type from pacman (#1) 2023-03-06 00:14:09 +01:00
740f4bd36a feat(conf): Limit snapshot name length (#1) 2023-03-06 00:13:33 +01:00
6a666c5c8e refactor(script): Don't use zfs-auto-snapshot, but add jq (#1) 2023-03-06 00:12:50 +01:00
1e56f28dc8 feat(hook): Use separate hook per transaction type (#1) 2023-03-05 22:19:12 +01:00
0e9290a727 docs(script): Trailing dot (#1) 2023-03-05 22:18:12 +01:00
ae42f40f46 refactor(script): For symlinks you're going to need sudo (#1) 2023-03-05 22:17:53 +01:00
52bc1ba132 refactor(script): Opposite of important is trivial (#1) 2023-03-05 22:17:21 +01:00
136ec7875e docs(conf): Add a note to ourselves for final defaults (#1) 2023-03-05 22:16:29 +01:00
1113a32888 feat(hook): Use separate hook per transaction type (#1) 2023-03-05 22:15:55 +01:00
f7f6d71250 feat(meta): Initial commit (#1) 2023-03-05 08:15:16 +01:00
6 changed files with 445 additions and 1 deletions

View File

@@ -1,3 +1,63 @@
# zfs-pacman-hook # zfs-pacman-hook
Arch Linux pacman hook for automatic snapshots Arch Linux pacman hook for automatic ZFS snapshots
# Setup
Get started like so:
1. Install dependency `jq`
1. Clone repo into arbitrary path `<repo>`
1. Make `pacman-zfs-snapshot.sh` executable
```
chmod +x <repo>/pacman-zfs-snapshot.sh
```
1. Symlink to files, for example
```
sudo ln -s <repo>/pacman-zfs-snapshot.sh /usr/local/bin/pacman-zfs-snapshot
sudo ln -s <repo>/pacman-zfs-snapshot-install.hook /usr/share/libalpm/hooks/pacman-zfs-snapshot-install.hook
sudo ln -s <repo>/pacman-zfs-snapshot-remove.hook /usr/share/libalpm/hooks/pacman-zfs-snapshot-remove.hook
sudo ln -s <repo>/pacman-zfs-snapshot-upgrade.hook /usr/share/libalpm/hooks/pacman-zfs-snapshot-upgrade.hook
sudo ln -s <repo>/pacman-zfs-snapshot.conf /etc/pacman-zfs-snapshot.conf
```
Note that while you may choose arbitrary locations for symlinks the `pacman-zfs-snapshot-*.hook` files reference `/usr/local/bin/pacman-zfs-snapshot`. Change that accordingly if you need to.
1. For datasets you want auto-snapshotted add property `space.quico:auto-snapshot=true`
```
zfs set space.quico:auto-snapshot=true zpool/root/archlinux
```
With any other property and any other value datasets will not be auto-snapshotted.
1. Adjust `pacman-zfs-snapshot.conf` to your liking. You may want to set `do_dry_run='true'` for a start and just reinstall a benign package to get a feel for what this hook would do.
# What's it do?
In `pacman` on every `PreTransaction`, meaning right before any actual operation on a package begins, we trigger a ZFS snapshot. By default we identify the active system dataset by doing `findmnt / --noheadings --output source`. If exactly one source returns that is the exact name of a ZFS dataset in an imported zpool we create a snapshot on it. If no source returns we silently exit. If more than one source returns we raise an error and halt the `pacman` transaction.
We retain two different snapshot chains, one for `pacman` transactions that only affect what we are calling _trivial_ packages and a separate chain for _important_ packages. By default only the exact regular expression package name match `^(linux)$` is considered important. Whenever an important package is affected by a transaction a snapshot goes into the corresponding chain. In all other cases - when an important package is not affected - snapshots go into the trivial chain.
The _trivial_ snapshot chain by default keeps 15 snapshots, the _important_ chain keeps 5. The thought process here is that you will likely not futz around with a kernel every day whereas you may very well install arbitrary packages multiple times a day. Snapshots should keep you safe for a couple of days hence the defaults of 5 and 15 snapshots, respectively.
You may optionally include more ZFS datasets in this snapshot mechanism. Have a look at `pacman-zfs-snapshot.conf`, its comments should be clear enough to get you going.
# Development
## Conventional commits
This project uses [Conventional Commits](https://www.conventionalcommits.org/) for its commit messages.
### Commit types
Commit _types_ besides `fix` and `feat` are:
- `build`: Project structure, directory layout, build instructions for roll-out
- `refactor`: Keeping functionality while streamlining or otherwise improving function flow
- `test`: Working on test coverage
- `docs`: Documentation for project or components
### Commit scopes
The following _scopes_ are known for this project. A Conventional Commits commit message may optionally use one of the following scopes or none:
- `conf`: How we deal with script config
- `script`: Any other script work that doesn't specifically fall into the above scopes
- `hook`: Configuring the hook(s)
- `meta`: Affects the project's repo layout, readme content, file names etc.

View File

@@ -0,0 +1,12 @@
[Trigger]
Operation = Install
Type = Package
Target = *
[Action]
Description = Create ZFS snapshot(s)
When = PreTransaction
Exec = /bin/sh -c 'while read -r f; do echo "$f"; done | /usr/local/bin/pacman-zfs-snapshot install'
Depends = jq
AbortOnFail
NeedsTargets

View File

@@ -0,0 +1,12 @@
[Trigger]
Operation = Remove
Type = Package
Target = *
[Action]
Description = Create ZFS snapshot(s)
When = PreTransaction
Exec = /bin/sh -c 'while read -r f; do echo "$f"; done | /usr/local/bin/pacman-zfs-snapshot remove'
Depends = jq
AbortOnFail
NeedsTargets

View File

@@ -0,0 +1,12 @@
[Trigger]
Operation = Upgrade
Type = Package
Target = *
[Action]
Description = Create ZFS snapshot(s)
When = PreTransaction
Exec = /bin/sh -c 'while read -r f; do echo "$f"; done | /usr/local/bin/pacman-zfs-snapshot upgrade'
Depends = jq
AbortOnFail
NeedsTargets

50
pacman-zfs-snapshot.conf Normal file
View File

@@ -0,0 +1,50 @@
# Set to 'true' to do nothing and just print messages during pacman
# operations. Helpful to get a feel for what these hooks do. This defaults
# to 'false' so if you set this to an empty string or remove or uncomment it
# in this conf file it'll equal 'false'.
do_dry_run='false'
# Pipe-separated list of package names we consider important. Will be
# matched against regular expression ^(this_var_here)$. Snapshots taken
# before a pacman transaction on an important package have a separate
# retention from snapshots for trivial packages. Lends itself to keeping
# high-risk updates separate from everything else.
important_names='linux|systemd|zfs-(dkms|utils)'
# Number snapshots to keep
snaps_trivial_keep='15'
snaps_important_keep='5'
# Which suffix to use in snapshot names to identify snapshots before a
# trivial pacman operation and before important pacman operations.
snaps_trivial_suffix='trv'
snaps_important_suffix='imp'
# Snapshot name will contain list of affected packages trimmed to this many
# max characters.
pkgs_list_max_length='30'
# Hook will by default snapshot all datasets that have the property
# 'space.quico:auto-snapshot=true' set, even the ones that are not currently
# mounted and may belong to unrelated operating systems. Set
# snap_only_local_datasets='true' to limit snapshots to only those datasets
# that have aforementioned property and at the same time are currently
# mounted in your running OS. Currently mounted is defined as:
# findmnt --json --list --output 'fstype,source,target' | \
# jq --raw-output '.[][] | select(.fstype=="zfs") | .source'
snap_only_local_datasets='true'
# Which characters do we want to use to separate snapshot name fields
snap_field_separator='_'
# Prefix all our snapshots with this string to keep them separate from
# snapshots done by any other means
snap_name_prefix='pacman'
# We do "$(date +<whatever>)" to put a timestamp into snapshot names.
# Defaults to "$(date +<whatever>)" which returns '2023-03-07-0050'.
snap_date_format='%F-%H%M'
# Which strings do we want to diffferentiate pacman operations Install,
# Remove, Upgrade
snap_op_installation_suffix='inst'
snap_op_remove_suffix='rmvl'
snap_op_upgrade_suffix='upgr'

298
pacman-zfs-snapshot.sh Executable file
View File

@@ -0,0 +1,298 @@
#!/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_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 pkg_separator max_zfs_snapshot_name_length color_reset color_lyellow color_red
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 'space.quico:auto-snapshot' 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' 'space.quico:auto-snapshot' | \
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}" == 'imp' ]]; 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 find_max_dataset_name_length () {
local longest_op_suffix op_suffix_string
longest_op_suffix='0'
for op_suffix in "${snap_op_installation_suffix}" "${snap_op_remove_suffix}" "${snap_op_upgrade_suffix}"; do
if [[ "${#op_suffix}" -gt "${longest_op_suffix}" ]]; then
longest_op_suffix="${#op_suffix}"
fi
done
op_suffix_string="$(head -c "${longest_op_suffix}" '/dev/zero' | tr '\0' '_')"
local longest_sev_suffix sev_suffix_string
longest_sev_suffix='0'
for sev_suffix in "${snaps_trivial_suffix}" "${snaps_important_suffix}"; do
if [[ "${#sev_suffix}" -gt "${longest_sev_suffix}" ]]; then
longest_sev_suffix="${#sev_suffix}"
fi
done
sev_suffix_string="$(head -c "${longest_sev_suffix}" '/dev/zero' | tr '\0' '_')"
local dataset_name_no_pkgs
max_dataset_name_length='0'
for dataset in "${snappable_datasets[@]}"; do
dataset_name_no_pkgs="${dataset}"'@'"${snap_name_prefix}${snap_field_separator}${date_string}${snap_field_separator}"'op:'"${op_suffix_string}${snap_field_separator}"'sev:'"${sev_suffix_string}${snap_field_separator}"'pkgs:'
if [[ "${#dataset_name_no_pkgs}" -gt "${max_dataset_name_length}" ]]; then
max_dataset_name_length="${#dataset_name_no_pkgs}"
fi
done
if [[ "${max_dataset_name_length}" -gt "${max_zfs_snapshot_name_length}" ]]; then
pprint 'warn' 'Snapshot name would exceed ZFS '"${max_zfs_snapshot_name_length}"' chars limit. Skipping snapshots ...' '0'
fi
}
function trim_pkg_list_oneline () {
local available_pkg_list_length
available_pkg_list_length="$((${max_zfs_snapshot_name_length} - ${max_dataset_name_length}))"
if [[ "${available_pkg_list_length}" -lt "${pkgs_list_max_length}" ]]; then
# If we have fewer characters available than the user wants limit
# package list length
pkgs_list_max_length="${available_pkg_list_length}"
fi
local shorter_pkg_list
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 [[ "${#shorter_pkg_list}" -gt "${pkgs_list_max_length}" ]]; then
# If this is still too long we empty the package list
shorter_pkg_list=''
fi
trimmed_pkg_list_oneline="${shorter_pkg_list}"
}
function do_snaps () {
local snap_name snap_return_code
for snappable_dataset in "${snappable_datasets[@]}"; do
snap_name="${snappable_dataset}"'@'"${snap_name_prefix}${snap_field_separator}${date_string}${snap_field_separator}"'op:'"${conf_op_suffix}${snap_field_separator}"'sev:'"${severity}${snap_field_separator}"'pkgs:'"${trimmed_pkg_list_oneline}"
if [[ "${do_dry_run}" == 'true' ]]; then
pprint 'info' 'Dry-run, pretending to run zfs snapshot '"${snap_name}"
else
zfs snapshot "${snap_name}"
snap_return_code="${?}"
if [[ "${snap_return_code}" -eq '0' ]]; then
successfully_snapped_datasets+=("${snappable_dataset}")
pprint 'info' 'Snapshot done: '"${snap_name}"
else
pprint 'warn' 'Snapshot failed: '"${snap_name}"
fi
fi
done
}
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
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
pprint 'info' 'Oldest in chain '"'"'sev:'"${severity}"''"'"' destroyed: '"${oldest_snap}"
else
pprint 'warn' 'Snapshot destruction failed: '"${oldest_snap}"
fi
done
done
fi
}
function main () {
local 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
else
snappable_datasets=("${globally_snappable_datasets}")
fi
local unabridged_pkg_list_oneline
write_pkg_list_oneline
local date_string max_dataset_name_length
date_string="$(date +"${snap_date_format}")"
find_max_dataset_name_length
local trimmed_pkg_list_oneline
trim_pkg_list_oneline
local -a successfully_snapped_datasets
do_snaps
do_retention
}
main "${pkgs[@]}"