Compare commits
11 Commits
f59d67fc90
...
6779c8265c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6779c8265c | |||
| 9b4af01de2 | |||
| 457db3934c | |||
| e227e27ec8 | |||
| 24a6ce31a4 | |||
| 1e587fbdea | |||
| 0a3b71ce1b | |||
| 97e023ee46 | |||
| 942128c89f | |||
| 71b1c8a106 | |||
| aa420d3507 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.idea
|
||||
7
90apt-zfs-snapshot.conf
Normal file
7
90apt-zfs-snapshot.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
# APT sends the package action list to the helper before invoking dpkg.
|
||||
DPkg::Tools::options::/usr/local/sbin/apt-zfs-snapshot::Version "3";
|
||||
DPkg::Tools::options::/usr/local/sbin/apt-zfs-snapshot::InfoFD "3";
|
||||
DPkg::Pre-Install-Pkgs
|
||||
{
|
||||
"/usr/local/sbin/apt-zfs-snapshot";
|
||||
};
|
||||
30
CONTRIBUTING.md
Normal file
30
CONTRIBUTING.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Contributing
|
||||
|
||||
## Commit conventions
|
||||
|
||||
This project uses Conventional Commits. Every commit must have a scope:
|
||||
|
||||
```text
|
||||
type(scope): imperative summary
|
||||
```
|
||||
|
||||
The project currently uses these commit types:
|
||||
|
||||
- `feat`: adds user-visible functionality
|
||||
- `fix`: corrects an incorrect or unsafe behavior
|
||||
- `refactor`: changes internal structure without changing intended behavior
|
||||
- `docs`: changes documentation only
|
||||
- `meta`: changes project conventions, repository metadata, or contribution files
|
||||
- `build`: changes installation, packaging, or deployment integration
|
||||
|
||||
The project currently uses these scopes:
|
||||
|
||||
- `apt`: APT and Debian package-manager behavior
|
||||
- `hook`: APT hook configuration and invocation wiring
|
||||
- `zfs`: ZFS selection, snapshot naming, and retention behavior
|
||||
- `conf`: Runtime configuration parameters and defaults
|
||||
- `docs`: README and explanatory documentation
|
||||
|
||||
Add a new type or scope to this document before using it. Keep commit subjects
|
||||
concise and imperative. Commit bodies should explain the motivation and the
|
||||
resulting behavior, with body lines no longer than 72 characters.
|
||||
127
README.md
127
README.md
@@ -1,3 +1,128 @@
|
||||
# apt-pacman-hook
|
||||
|
||||
Ubuntu apt hook for automatic snapshots
|
||||
Ubuntu APT hook for automatic ZFS snapshots.
|
||||
|
||||
This is the Ubuntu/APT counterpart to [zfs-pacman-hook](https://quico.space/quico-os-setup/zfs-pacman-hook). It snapshots selected ZFS datasets before APT invokes `dpkg`, includes affected Debian package names in snapshot names, and prunes old snapshots using separate ordinary and important-package retention chains.
|
||||
|
||||
## Setup
|
||||
|
||||
Clone this repository into an arbitrary location `<repo>`. Install the
|
||||
executable and APT hook as symlinks so that changes in the checkout are
|
||||
immediately available. Install the runtime configuration as a copy so that
|
||||
local configuration is not tied to the repository checkout:
|
||||
|
||||
```bash
|
||||
sudo chmod +x <repo>/apt-zfs-snapshot.sh
|
||||
sudo ln -s <repo>/apt-zfs-snapshot.sh \
|
||||
/usr/local/sbin/apt-zfs-snapshot
|
||||
sudo ln -s <repo>/90apt-zfs-snapshot.conf \
|
||||
/etc/apt/apt.conf.d/90apt-zfs-snapshot.conf
|
||||
sudo install -Dm644 <repo>/apt-zfs-snapshot.conf \
|
||||
/etc/apt-zfs-snapshot.conf
|
||||
```
|
||||
|
||||
The symlink targets must remain at the paths used in the commands above. If
|
||||
you choose different target locations, update the executable path in
|
||||
`90apt-zfs-snapshot.conf` as well.
|
||||
|
||||
Set `dry_run='true'` in `/etc/apt-zfs-snapshot.conf`, run a harmless APT operation, and inspect the planned names. Set it back to `false` after verifying dataset selection and naming.
|
||||
|
||||
The helper runs as root through APT and requires Bash, `zfs`, and `findmnt`.
|
||||
|
||||
## APT integration
|
||||
|
||||
The hook uses `DPkg::Pre-Install-Pkgs`. APT sends its version-3 package action protocol on the file descriptor named by `APT_HOOK_INFO_FD`. Records contain the package name, old and new versions, version direction, architecture, and action. This captures dependencies selected by APT as well as explicitly requested packages.
|
||||
|
||||
Actions are classified as follows:
|
||||
|
||||
| APT record | Operation marker |
|
||||
| --- | --- |
|
||||
| No old version and a new version | `inst` |
|
||||
| New version greater than old version | `upgr` |
|
||||
| New version lower than old version | `down` |
|
||||
| `**REMOVE**` action | `rmvl` |
|
||||
|
||||
Configuration-only records are ignored. If one callback contains multiple operation classes, the marker is `mixd`. In normal Ubuntu use, an `apt-get -y dist-upgrade` produces one useful callback and therefore one snapshot. APT can invoke `dpkg` more than once for complex operations; this implementation creates one snapshot per package-action callback.
|
||||
|
||||
## Dataset selection
|
||||
|
||||
By default, datasets with this exact ZFS property are selected:
|
||||
|
||||
```bash
|
||||
zfs set space.quico:auto-snapshot=true rpool/ROOT/ubuntu
|
||||
```
|
||||
|
||||
With `snap_only_local_datasets='true'`, only mounted ZFS datasets are selected. This avoids snapshotting another operating system stored in the same pool. To explicitly select roots, set:
|
||||
|
||||
```bash
|
||||
snapshot_roots='bpool/BOOT rpool/ROOT'
|
||||
```
|
||||
|
||||
Explicit roots bypass property discovery and the mounted-dataset check. Each root is snapshotted recursively, and all roots are passed to one `zfs snapshot -r` command.
|
||||
|
||||
## Snapshot names
|
||||
|
||||
Names use this form:
|
||||
|
||||
```text
|
||||
dataset@apt_DATE_COUNTER_op:OP_sev:SEVERITY_pkgs:PACKAGE-LIST
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
rpool/ROOT/ubuntu@apt_2026-09-16-1430_1_op:upgr_sev:imp_pkgs:linux-image-6.17:systemd:libc6
|
||||
```
|
||||
|
||||
The timestamp is UTC by default and is controlled by `date_format` and `timezone`. The counter starts at one and increases if a complete name already exists. This prevents collisions when identical operations happen within the same timestamp interval.
|
||||
|
||||
Package names are separated with `package_separator`, which defaults to `:`. Plus signs are replaced with underscores before inclusion in a ZFS name. The package list is shortened by dropping complete names from the end first, then truncating the final remaining name and adding `...` when at least four characters fit. `packages_max_length` is a best effort because the complete ZFS name is limited to 255 characters. If the fixed dataset and metadata fields cannot fit, the hook refuses to create a snapshot.
|
||||
|
||||
## Retention
|
||||
|
||||
If any affected package matches `important_names`, the snapshot belongs to the important chain. Otherwise it belongs to the trivial chain. The default important expression is:
|
||||
|
||||
```text
|
||||
linux-image(-.*)?|linux-headers(-.*)?|systemd|zfs-(dkms|utils)
|
||||
```
|
||||
|
||||
The defaults retain 25 trivial snapshots and 10 important snapshots. Retention runs only after a successful snapshot and only considers snapshots with this tool's prefix and the current severity marker. Recursive snapshot trees are destroyed with `zfs destroy -r`.
|
||||
|
||||
## Configuration
|
||||
|
||||
`/etc/apt-zfs-snapshot.conf` is sourced as a Bash fragment. Available parameters are:
|
||||
|
||||
| Parameter | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `dry_run` | `false` | Print planned snapshots without changing ZFS |
|
||||
| `important_names` | kernel/systemd/ZFS expression | Exact regex for important packages |
|
||||
| `snapshots_trivial_keep` | `25` | Trivial snapshots retained per dataset |
|
||||
| `snapshots_important_keep` | `10` | Important snapshots retained per dataset |
|
||||
| `trivial_suffix` | `trv` | Ordinary severity marker |
|
||||
| `important_suffix` | `imp` | Important severity marker |
|
||||
| `packages_max_length` | `30` | Maximum package-list characters |
|
||||
| `snap_only_local_datasets` | `true` | Restrict property-selected datasets to mounted datasets |
|
||||
| `snapshot_roots` | empty | Explicit space-separated recursive snapshot roots |
|
||||
| `field_separator` | `_` | Separator between snapshot fields |
|
||||
| `package_separator` | `:` | Separator between package names |
|
||||
| `snapshot_prefix` | `apt` | Prefix identifying these snapshots |
|
||||
| `date_format` | `%F-%H%M` | Format passed to `date` |
|
||||
| `timezone` | `Etc/UTC` | Timestamp timezone |
|
||||
| `install_suffix` | `inst` | Install marker |
|
||||
| `remove_suffix` | `rmvl` | Remove marker |
|
||||
| `upgrade_suffix` | `upgr` | Upgrade marker |
|
||||
| `downgrade_suffix` | `down` | Downgrade marker |
|
||||
| `mixed_suffix` | `mixd` | Multiple operation classes marker |
|
||||
|
||||
The property name and value are fixed to `space.quico:auto-snapshot=true`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- The hook covers package changes when APT invokes `dpkg`; direct `dpkg` operations are not intercepted.
|
||||
- APT may invoke `dpkg` more than once in complex cases, producing more than one snapshot.
|
||||
- The snapshot occurs before the corresponding `dpkg` invocation, not before APT downloads packages or resolves dependencies.
|
||||
- Snapshot failures are reported but do not abort the package operation, matching the warning-oriented original behavior.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
34
apt-zfs-snapshot.conf
Normal file
34
apt-zfs-snapshot.conf
Normal file
@@ -0,0 +1,34 @@
|
||||
# Set true to print planned snapshots without creating or destroying anything.
|
||||
dry_run='false'
|
||||
|
||||
# Exact package-name regular expression (without the surrounding ^ and $) for
|
||||
# packages whose snapshots use the important retention chain.
|
||||
important_names='linux-image(-.*)?|linux-headers(-.*)?|systemd|zfs-(dkms|utils)'
|
||||
|
||||
# Number of snapshots retained in each chain.
|
||||
snapshots_trivial_keep='25'
|
||||
snapshots_important_keep='10'
|
||||
trivial_suffix='trv'
|
||||
important_suffix='imp'
|
||||
|
||||
# Maximum number of package-list characters in a snapshot name. A package list
|
||||
# is shortened by dropping complete names from the end first, then truncating
|
||||
# the final remaining name and adding ... when at least four characters fit.
|
||||
packages_max_length='30'
|
||||
|
||||
# When true, only mounted ZFS datasets are selected. Set snapshot_roots to an
|
||||
# explicit space-separated list to bypass property discovery and mount checks.
|
||||
snap_only_local_datasets='true'
|
||||
snapshot_roots=''
|
||||
|
||||
# Snapshot name formatting.
|
||||
field_separator='_'
|
||||
package_separator=':'
|
||||
snapshot_prefix='apt'
|
||||
date_format='%F-%H%M'
|
||||
timezone='Etc/UTC'
|
||||
install_suffix='inst'
|
||||
remove_suffix='rmvl'
|
||||
upgrade_suffix='upgr'
|
||||
downgrade_suffix='down'
|
||||
mixed_suffix='mixd'
|
||||
251
apt-zfs-snapshot.sh
Executable file
251
apt-zfs-snapshot.sh
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/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 successful_datasets
|
||||
declare existing_snapshots
|
||||
|
||||
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 _old_multiarch direction new_version _new_arch _new_multiarch action <<<"${line}"
|
||||
[[ -n "${package}" ]] || continue
|
||||
if [[ "${action}" == '**CONFIGURE**' ]]; then
|
||||
continue
|
||||
elif [[ "${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 candidate parent
|
||||
local -a candidates
|
||||
local mounted_datasets
|
||||
if [[ -n "${snapshot_roots}" ]]; then
|
||||
read -r -a candidates <<<"${snapshot_roots}"
|
||||
snappable_datasets=("${candidates[@]}")
|
||||
return 0
|
||||
fi
|
||||
|
||||
mounted_datasets="$(findmnt -rn -t zfs -o SOURCE)"
|
||||
while IFS=$'\t' read -r dataset value; do
|
||||
[[ "${value}" == "${zfs_property_value}" && "${dataset}" != *@* ]] || continue
|
||||
if [[ "${snap_only_local_datasets}" != 'true' ]] || grep -Fxq -- "${dataset}" <<<"${mounted_datasets}"; then
|
||||
candidates+=("${dataset}")
|
||||
fi
|
||||
done < <(zfs get -H -t filesystem,volume -o name,value "${zfs_property}")
|
||||
|
||||
# An inherited property marks every descendant. Keep only the highest
|
||||
# selected dataset because the snapshot operation is recursive.
|
||||
for dataset in "${candidates[@]}"; do
|
||||
for parent in "${candidates[@]}"; do
|
||||
[[ "${dataset}" == "${parent}"/* ]] && continue 2
|
||||
done
|
||||
snappable_datasets+=("${dataset}")
|
||||
done
|
||||
}
|
||||
|
||||
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() { grep -Fxq -- "$1" <<<"${existing_snapshots}"; }
|
||||
|
||||
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_result}${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
|
||||
return 0
|
||||
}
|
||||
|
||||
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 "${successful_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; }
|
||||
existing_snapshots="$(zfs list -H -t snapshot -o name)"
|
||||
date_string="$(TZ="${timezone}" date +"${date_format}")"
|
||||
severity_and_packages
|
||||
shorten_packages
|
||||
operation_suffix_result="$(operation_suffix)"
|
||||
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
|
||||
successful_datasets=()
|
||||
local name dataset snapshot_failed='false'
|
||||
for name in "${names[@]}"; do
|
||||
dataset="${name%%@*}"
|
||||
if zfs snapshot -r "${name}"; then
|
||||
successful_datasets+=("${dataset}")
|
||||
printf '[INFO] Created ZFS snapshots for %s:\n' "${dataset}" >&2
|
||||
printf ' %s\n' "${name}" >&2
|
||||
else
|
||||
snapshot_failed='true'
|
||||
print_msg WARN "ZFS snapshot failed for ${dataset}"
|
||||
fi
|
||||
done
|
||||
[[ "${snapshot_failed}" == 'false' ]] || print_msg WARN 'Snapshot set is incomplete'
|
||||
((${#successful_datasets[@]} > 0)) && prune_snapshots
|
||||
}
|
||||
|
||||
main
|
||||
Reference in New Issue
Block a user