Repository navigation
Expand file tree
/
Copy pathdpkg-pre-post-snapper.sh
More file actions
executable file
·330 lines (284 loc) · 11.4 KB
/
Copy pathdpkg-pre-post-snapper.sh
File metadata and controls
executable file
·330 lines (284 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
#!/usr/bin/env bash
#
# dpkg-pre-post-snapper.sh
#
# Creates paired Snapper pre/post snapshots around package transactions
# and derives descriptive snapshot names from the initiating process.
#
# Version: 1.0.1
# License: GPL-2.0-only
#
# Based on Debian's 80snapper APT-hook implementation introduced through
# Debian Bug #770938, and on the external-script implementation and
# process-tree detection developed by imthenachoman.
#
# Subsequent enhancements and maintenance:
# Copyright (C) 2026 Scott McClain
# SPDX-License-Identifier: GPL-2.0-only
# Walk up the parent process tree to identify the application that
# initiated the package transaction and use its command line as the
# Snapper snapshot description.
#
# Known package managers (apt, apt-get, aptitude, unattended-upgr,
# synaptic and muon) use their own command line directly.
#
# PackageKit transactions do not preserve the frontend application in
# the process tree because they communicate over D-Bus. When a
# PackageKit backend is detected, use the running Discover command line
# as a best-effort description if available; otherwise identify the
# transaction simply as "PackageKit".
#
# If no recognized caller is found, fall back to
# "apt (unrecognized caller)".
###############################################################################
# Implementation Notes
###############################################################################
# This APT hook intentionally does not use `set -euo pipefail`.
# Expected command failures are handled explicitly throughout the
# script, and enabling strict mode can change normal APT hook behavior.
PID=$$
# Tools whose invocation we know how to label.
KNOWN_CMDS=(
apt
apt-get
aptitude
unattended-upgr
synaptic
muon
packagekitd # PackageKit daemon
PK-Backend # PackageKit worker process (comm name)
)
is_known_cmd() {
local comm="$1"
local k
for k in "${KNOWN_CMDS[@]}" ; do
[[ "$comm" == "$k" ]] && return 0
done
return 1
}
format_snapper_description() {
local cmdline="$1"
local -a toks pkgs
local cmd="" action="" first=""
local count=0
# Split on shell-like whitespace (adequate for apt invocations)
read -r -a toks <<< "$cmdline"
# Skip executable name
local i=1
# Skip global options before subcommand
while (( i < ${#toks[@]} )) && [[ ${toks[$i]} == -* ]]; do
((i++))
done
(( i < ${#toks[@]} )) || { printf '%s' "$cmdline"; return; }
action="${toks[$i]}"
((i++))
case "$action" in
update|upgrade|full-upgrade|dist-upgrade|autoremove)
printf '%s' "$action"
return
;;
install|remove|purge|build-dep)
;;
*)
printf '%s' "$cmdline"
return
;;
esac
while (( i < ${#toks[@]} )); do
if [[ ${toks[$i]} != -* ]]; then
pkgs+=("${toks[$i]}")
fi
((i++))
done
if (( ${#pkgs[@]} == 0 )); then
printf '%s' "$action"
return
fi
first="${pkgs[0]}"
count=$((${#pkgs[@]}-1))
if (( count == 0 )); then
printf '%s %s' "$action" "$first"
else
printf '%s %s +%d pkg' "$action" "$first" "$count"
fi
}
# Hard cap on how far up the process tree we'll walk. The loop already
# terminates correctly when ps returns nothing (PID goes empty) or we
# reach PID 1, but this guards against a pathological ps failure mode
# (e.g. a stuck/repeating ppid) ever turning into an infinite loop.
MAX_HOPS=1000
HOPS=0
MATCHED=0
while [[ -n "$PID" && "$PID" != "1" ]] ; do
HOPS=$((HOPS + 1))
if (( HOPS > MAX_HOPS )) ; then
echo "dpkg-pre-post-snapper: PID walk exceeded ${MAX_HOPS} hops, aborting" >&2
PID=""
break
fi
# Grab comm and the full command line together, from the same ps
# snapshot, so that if this PID turns out to be our match we
# already have its args without a second, later ps call that
# could race against the process exiting or re-execing.
read -r COMM CANDIDATE < <(ps -ho comm,args "${PID}" 2>/dev/null)
if is_known_cmd "$COMM" ; then
MATCHED=1
break
fi
PID=$(ps -ho ppid "$PID" 2>/dev/null | xargs)
done
# Only re-query comm here for the fall-through cases (walked to PID 1,
# or the walk was aborted above) - the matched case already has COMM
# and CANDIDATE captured atomically from inside the loop.
if [[ "$MATCHED" -ne 1 ]] ; then
COMM="$(ps -ho comm "${PID}" 2>/dev/null)"
fi
SNAPPER_DESCRIPTION="apt"
if [[ "$MATCHED" -eq 1 ]] ; then
if [[ "$COMM" == "packagekitd" || "$COMM" == "PK-Backend" ]] ; then
# PackageKit doesn't preserve the GUI frontend in the process
# tree because it communicates over D-Bus. If Discover is
# currently running, use its command line as a best-effort
# description. Otherwise, identify the transaction as
# PackageKit rather than pretending we know the frontend.
DISCOVER_PID="$(pidof plasma-discover plasma-discover-notifier 2>/dev/null | awk '{print $1}')"
if [[ -n "$DISCOVER_PID" ]] ; then
CANDIDATE="$(ps -ho args "$DISCOVER_PID" 2>/dev/null | cut -c -112)"
[[ -n "$CANDIDATE" ]] && SNAPPER_DESCRIPTION="$CANDIDATE"
else
SNAPPER_DESCRIPTION="PackageKit"
fi
else
[[ -n "$CANDIDATE" ]] && SNAPPER_DESCRIPTION="$(format_snapper_description "$CANDIDATE")"
fi
else
SNAPPER_DESCRIPTION="apt (unrecognized caller)"
fi
# Run a snapper command, retrying with backoff if snapper reports the
# config is "in use". This happens when a prior snapper invocation
# (e.g. the create right before this one) hasn't fully released its
# internal per-config lock yet - a transient race, not a real failure.
# Retrying on the specific condition is more robust than a fixed sleep:
# it does nothing extra when there's no contention, and keeps waiting
# if contention happens to last longer than expected.
#
# Stdout is left untouched so callers can still redirect it normally
# (e.g. "snapper_run -c root create -p > /var/tmp/snapper-apt").
# Stderr is captured only so we can inspect it for the "in use" string;
# it's replayed on the final attempt (success or failure) so nothing
# is silently lost.
snapper_run() {
local rc attempt=1 max_attempts=10 delay=0.2 stderr_file
stderr_file="$(mktemp)"
while : ; do
snapper "$@" 2>"$stderr_file"
rc=$?
if [[ $rc -eq 0 ]] ; then
cat "$stderr_file" >&2
rm -f "$stderr_file"
return 0
fi
if grep -qi 'in use' "$stderr_file" && [[ $attempt -lt $max_attempts ]] ; then
attempt=$((attempt + 1))
sleep "$delay"
delay="$(awk -v d="$delay" 'BEGIN { printf "%.2f", d * 1.5 }')"
continue
fi
cat "$stderr_file" >&2
rm -f "$stderr_file"
return $rc
done
}
# main event
# Serialize the entire pre/post logic below across concurrent
# invocations of this script. dpkg's Pre-/Post-Invoke hooks are meant
# to run one transaction at a time, but two independent package
# managers (e.g. a background unattended-upgrades run overlapping with
# a manual apt install) can genuinely invoke dpkg - and therefore this
# script - at the same time. Without serialization, two instances can
# race on /var/tmp/snapper-apt (one reading/deleting it while the
# other is mid-use) and can also hit snapper's own "config in use"
# error from two near-simultaneous snapper calls. Waiting here for a
# prior instance to fully finish (through its own file cleanup) fixes
# the race at the source, rather than papering over its symptoms.
LOCKFILE="/var/lock/dpkg-pre-post-snapper.lock"
exec 200>"$LOCKFILE"
if ! flock -w 300 200 ; then
echo "dpkg-pre-post-snapper: timed out waiting for lock, skipping snapshot for this transaction" >&2
exit 0
fi
# source /etc/default/snapper if it exists
if [ -e /etc/default/snapper ] ; then
. /etc/default/snapper
fi
# what action are we taking
if [ "$1" = "pre" ] ; then
# pre, so take a pre snapshot
# if snapper is installed
# and if snapper snapshots are not being disabled using the DISABLE_APT_SNAPSHOT variable
# and if /etc/snapper/configs/root exists
if [ -x /usr/bin/snapper ] && [ "${DISABLE_APT_SNAPSHOT}" != "yes" ] && [ -e /etc/snapper/configs/root ] ; then
# If a pre snapshot is already pending, don't create another one.
# APT/dpkg may invoke Pre-Invoke more than once during a single
# package transaction. Reuse the existing pre snapshot instead.
if [[ ! -e /var/tmp/snapper-apt ]]; then
snapper_run -c root create \
-d "${SNAPPER_DESCRIPTION}" \
-c number \
-t pre \
-p > /var/tmp/snapper-apt || true
snapper_run -c root cleanup number || true
fi
fi
elif [ "$1" = "post" ] ; then
# Verify snapper is installed, snapshots aren't disabled, and the pre-ID tracking file exists
if [ -x /usr/bin/snapper ] && [ "${DISABLE_APT_SNAPSHOT}" != "yes" ] && [ -e /var/tmp/snapper-apt ] ; then
# 1. Read and store the pre-snapshot ID number safely into a variable
read -r PRE_SNAP_ID < /var/tmp/snapper-apt
# 2. Extract the current description of that pre-snapshot.
# We explicitly request only the two columns we need via
# --columns, rather than parsing whatever default column set
# snapper happens to emit - this means a future snapper
# version adding, removing, or reordering its default
# columns (like the extra "Userdata" column this version
# already has) can't affect us: we're never touching
# "whatever the default is". --jsonout is used instead of
# CSV so field access is by name everywhere in the pipeline,
# with no positional/index assumptions at all. Both
# --columns and --jsonout are part of snapper's documented
# machine-readable interface (since snapper 0.8.6).
CURRENT_DESC="$(
snapper_run -c root --jsonout list --columns number,description |
python3 -c '
import json, sys
want = sys.argv[1]
try:
data = json.load(sys.stdin)
except ValueError:
sys.exit(0)
for rows in data.values():
for row in rows:
if str(row.get("number")) == want:
print(row.get("description") or "")
sys.exit(0)
' "$PRE_SNAP_ID"
)"
# 3. Only repair descriptions that are empty or exactly "apt".
# Never overwrite an existing descriptive string such as
# "apt install firefox" or "apt upgrade".
if [ -z "$CURRENT_DESC" ] || [ "$CURRENT_DESC" = "apt" ]; then
if [ -n "$SNAPPER_DESCRIPTION" ] && [ "$SNAPPER_DESCRIPTION" != "apt" ]; then
snapper_run -c root modify -d "${SNAPPER_DESCRIPTION}" "${PRE_SNAP_ID}"
CURRENT_DESC="${SNAPPER_DESCRIPTION}"
fi
fi
# 4. Take the post snapshot matching the target description perfectly
snapper_run -c root create -t post --pre-number="${PRE_SNAP_ID}" -d "${CURRENT_DESC}" -c number || true
# 5. Clean up snapper snapshots using the standard configuration timeline limits.
# (No fixed sleep here - snapper_run retries on its own if the
# config still looks "in use" from the create above.)
snapper_run -c root cleanup number || true
# 6. Delete the temporary tracking file
rm -f /var/tmp/snapper-apt || true
fi
fi