Compare commits

..

12 Commits

Author SHA1 Message Date
44ea66026b refactor(conf): Update Ubuntu default package strings 2026-09-16 10:20:54 +02:00
6779c8265c refactor(meta): Remove 'set -xv' debug 2026-09-16 10:03:13 +02:00
9b4af01de2 fix(apt): snapshot recursive roots separately 2026-09-16 10:01:41 +02:00
457db3934c fix(apt): cache snapshot collision checks
Cache the complete snapshot-name listing once per hook invocation instead\nof querying ZFS separately for every dataset and counter candidate. This\nremoves the repeated full-pool scan that becomes expensive when recursive\nsnapshot histories are large.\n\nCache the operation suffix as well and return an explicit success status\nfrom snapshot-name generation so collision retries are unambiguous. Ignore\nAPT configuration-only records because they do not represent package data\nchanges requiring a new pre-install snapshot.
2026-09-16 09:46:01 +02:00
e227e27ec8 docs(docs): separate symlink and config installation
Document the executable and APT hook as symlinks into the repository\ncheckout. This keeps development changes immediately available at the\ninstalled paths.\n\nKeep the runtime configuration as an installed copy so local settings remain\nindependent from the checkout. Explain the expected paths and the need to\nupdate the APT executable path when choosing different locations.
2026-09-16 09:27:02 +02:00
24a6ce31a4 feat(meta): Ignore JetBrains files 2026-09-16 09:16:12 +02:00
1e587fbdea refactor(docs): No need to reference prior implementations 2026-09-16 09:15:39 +02:00
0a3b71ce1b docs(docs): explain APT snapshot operation
Document installation, APT protocol handling, dataset selection, recursive
snapshots, naming fields, package shortening, retention chains, and every
runtime configuration parameter.

Explain the relationship with the existing Pre-Invoke stop-gap and make the
expected callback frequency and direct-dpkg limitation explicit. Include
examples that show how affected package names appear in snapshot names.
2026-09-16 09:05:33 +02:00
97e023ee46 build(conf): add snapshot runtime defaults
Provide a deployable configuration with the default retention limits,
important package expression, dataset selection behavior, and snapshot name
formatting parameters.

Expose explicit recursive roots for installations such as bpool and rpool,
while retaining property-based selection as the default behavior. Document
operation suffixes for installs, removals, upgrades, downgrades, and mixed
APT callbacks through configuration values.
2026-09-16 09:05:24 +02:00
942128c89f feat(apt): add package-aware snapshot helper
Parse the APT package action protocol and classify records by operation.
Use the package list to determine important and trivial snapshot chains,
then generate collision-resistant names containing shortened package data.

Select ZFS datasets through the configured property or explicit roots.
Create recursive snapshots atomically, support dry-run output, and prune
old snapshots from the matching retention chain after success.

Preserve the original warning-oriented behavior by reporting snapshot and
retention failures without aborting the package operation.
2026-09-16 09:05:17 +02:00
71b1c8a106 build(hook): wire APT package action callbacks
Install the APT configuration fragment used to invoke the snapshot
helper before dpkg processes package files.

Request protocol version 3 and a dedicated information file descriptor so
the helper can classify package installations, upgrades, downgrades, and
removals without attempting to infer actions from the apt command line.
2026-09-16 09:05:13 +02:00
aa420d3507 meta(meta): document commit types and scopes
Define the Conventional Commits types currently used by the project.
Document the initial scope vocabulary for APT integration, hook wiring,
ZFS behavior, configuration, and documentation.

Require every future commit to carry a scope and keep verbose commit body
lines within the repository's 72-character limit. Require new types and
scopes to be documented before they are used.
2026-09-16 09:05:08 +02:00
6 changed files with 449 additions and 1 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea

7
90apt-zfs-snapshot.conf Normal file
View 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
View 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
View File

@@ -1,3 +1,128 @@
# apt-pacman-hook # 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
View 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-(firmware|headers|image)(-.*)?|systemd|zfs'
# 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
View 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