Package Omawake 0.0.3 and Omaspeak 0.0.3 with service-removal cleanup hooks (#503)
* Package Omawake 0.0.3 and Omaspeak 0.0.2 Bump both -bin packages to the model-support roadmap delivery: Omawake 0.0.3: - W02-W05 setup/activation/cache gates audited and closed - W07 pinned catalog URL health checks and import diagnostics - W08 Moonshine Small/Medium benchmarked; both deferred (Tiny default) - W09 Spanish wake profile (multilingual Whisper Base INT8, es) - W10 connection-owned playback pauses (HoldPause) Omaspeak 0.0.2: - S09 Kokoro 82M: Kokoro-capable packaged provider (supertonic;kokoro_tts) with espeak-ng-data.bin shipped beside the executable, 54 named voices - S10 catalog URL checks, Spanish speech profile, consistent status shape - S07 streaming deferred at the current pin Upstream: omawake v0.0.3, omaspeak v0.0.2 (aarch64 + x86_64 verified on promaxgb10-d666 CUDA and local NPU installs). * omaspeak-bin: install espeak-ng-data.bin beside the packaged library * omaspeak-bin: bump to 0.0.3-rc.1 (catalog-managed eSpeak data) - The tarball no longer ships espeak-ng-data.bin: the Kokoro catalog row pins the data package as a model asset (downloaded/verified/installed into the model directory with the GGUF), so the core package ships no model data at all. - Both arch checksums taken from the v0.0.3-rc.1 SHA256SUMS.txt. * omaspeak-bin: finalize at 0.0.3 * Stop setup-created Oma services before pacman removes their binaries * Drop stale release-verification fixtures from the branch These were swept in by git add -A during the version bumps: packaged copies of old releases (0.0.1 tarballs and extracted trees, ~80 MB) belong to the local verification workflow, not to the package repo. The consolidated upstream PR should carry only the package changes, hooks and the removal regression suite. * Update removal-test fixture versions to the packaged finals * Ask systemd to reset only an Oma unit that actually failed The removal helper reset the failed state of every unit it stopped, but systemd accepts ResetFailed for a unit that is in the failed state alone. For any other state it answers that the unit is not loaded and exits non-zero, and because the helper runs under errexit while the hook aborts on failure, a healthy unit then aborted the whole transaction: (2/2) Stop and remove omaspeak user services before package removal Failed to reset failed state of unit omaspeak.service: Unit omaspeak.service not loaded. :: Could not clean up omaspeak for jacob; removal aborted. That is the ordinary case, as the packaged service ships disabled and an enabled one is commonly stopped rather than failed. Read the active state after the stop and ask for the reset only where it applies, so a failed unit still loses its failed state along with its rate and restart counters while a clean unit no longer fails the removal. A reset that a reachable manager still refuses stays fatal. Model the rule in the removal suite, where reset-failed now follows the active state the way a real manager does, and cover both outcomes: an inactive unit must not be asked for, a failed one must be reset between the stop and the disable, and a refused reset must still fail the hook. * Keep removal cleanup faithful to how systemd reads configuration Both defects from the review of e025111 sat in the shared package-remove helper, so both packages were affected the same way. An offline user's drop-in was recognised by grep '^ExecStart=', while the configuration parser throws away the whitespace around an assignment (parse_line() strips the line and both halves of the assignment). A drop-in naming a development build as ExecStart = ExecStart = /home/alice/build/omawake daemon therefore went unmatched, and the helper cleared away the generated base unit beside with its enablement links, right behind a service that was never meant to be the package's. Match an assignment the way the parser accepts one. The gate that decides whether a unit file is the generated one stays strict on purpose: only the exact generated shape is ever deleted. systemctl show-environment also prints every value the way a shell would read it, through shell_maybe_quote(SHELL_ESCAPE_POSIX), so an XDG_CONFIG_HOME with a space arrives as $'/home/alice/custom config'. The XDG_CONFIG_HOME=/* case saw neither form and kept the home's .config directory quietly, leaving the unit in the directory the manager really reads pointing at the removed binary. Decode that quoting character by character, without letting the text become shell syntax, and refuse a value that is neither a plain path nor a closed $'...' quote rather than delete what would have to be guessed at. A value that is not an absolute path stays the fallback it is in systemd itself. The fixtures now hand the helper the very text a manager prints, quoted by a mirror of that printer, and cover a quoted path with a space, an apostrophe and a backslash, an unreadable quoted value, a relative one, and each spacing of an offline override. Verified with the removal suite, 15 tests; the eight new assertions fail against the helper as it was. The other suites were not run here, as they reach for the network. pkgrel 3 -> 4 and the helper's checksum, in both recipes. Reported-by: spencerbull * Decode systemd control escapes during service removal systemctl C-escapes control bytes in show-environment output. Rejecting those valid values aborted package removal for every user, even when the affected account had no Oma service. Decode the printer’s named and octal escapes without evaluating shell syntax or stripping trailing newlines, and cover the real printer format in the fixtures. Co-Authored-By: GPT-6 XHigh <noreply@openai.com> --------- Co-authored-by: Spencer Bull <spencer@omarchy.org> Co-authored-by: GPT-6 XHigh <noreply@openai.com>
This commit is contained in:
9 files changed
+695
-10
No files matched your search
@@ -42,6 +42,7 @@ jobs:
|
||||
archlinux:base-devel bash -lc '
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm git jq python libarchive
|
||||
python tests/oma-service-removal.py
|
||||
python tests/upstream-watch.py
|
||||
./bin/sync-upstream self-test
|
||||
./bin/sync-rebuilds --self-test
|
||||
|
||||
@@ -40,3 +40,4 @@ pkgbuilds/yay/yay/
|
||||
|
||||
# Python helpers and offline tests
|
||||
__pycache__/
|
||||
.release-verification/
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
pkgname=omaspeak-bin
|
||||
_pkgname=${pkgname%-bin}
|
||||
pkgver=0.0.1
|
||||
_upstream_ver=0.0.1
|
||||
pkgrel=1
|
||||
pkgver=0.0.3
|
||||
_upstream_ver=0.0.3
|
||||
pkgrel=4
|
||||
pkgdesc='Local-first text-to-speech application and daemon (pre-built binary)'
|
||||
arch=('x86_64' 'aarch64')
|
||||
url='https://github.com/jacob-vincent-mink/omaspeak'
|
||||
@@ -27,13 +27,21 @@ conflicts=("${_pkgname}")
|
||||
install="${pkgname}.install"
|
||||
options=('!strip' '!debug')
|
||||
|
||||
source=('package-remove' 'remove-user-services.hook')
|
||||
sha256sums=('f1b527448529b45fee2f96b4c0a19b11087377c0ac9f43842bab0d04fcfd3b9f'
|
||||
'9f1a0c2f5031fcd5905de77643a8727b792e07c582c09c2ba179f0714f118b20')
|
||||
|
||||
source_x86_64=("${_pkgname}-${_upstream_ver}-linux-x86_64.tar.xz::${url}/releases/download/v${_upstream_ver}/${_pkgname}-${_upstream_ver}-linux-x86_64.tar.xz")
|
||||
source_aarch64=("${_pkgname}-${_upstream_ver}-linux-aarch64.tar.xz::${url}/releases/download/v${_upstream_ver}/${_pkgname}-${_upstream_ver}-linux-aarch64.tar.xz")
|
||||
|
||||
sha256sums_x86_64=('9e318960fb15fdf955efbb8dda9bc8eb2b9d0932a9acd9e31b85bf3492ca78ea')
|
||||
sha256sums_aarch64=('8a0d7728d0b6d3f447ab7a616389fc8c54a0617f14922b33593ca60b425deb8d')
|
||||
sha256sums_x86_64=('c72428bf6989582b5aa802f39e9390e4cacf26f7fbfacf76645118286c7d1ab2')
|
||||
sha256sums_aarch64=('88fc4ea8c275b9d5b9d41602d32dbca77ee30de0fc7dcbc06ad0e819fd41545a')
|
||||
|
||||
package() {
|
||||
install -Dm755 "${srcdir}/package-remove" "${pkgdir}/usr/lib/${_pkgname}/package-remove"
|
||||
install -Dm644 "${srcdir}/remove-user-services.hook" \
|
||||
"${pkgdir}/usr/share/libalpm/hooks/30-${_pkgname}-remove-user-services.hook"
|
||||
|
||||
local release_root="${srcdir}/${_pkgname}-${_upstream_ver}-linux-${CARCH}"
|
||||
|
||||
install -Dm755 "${release_root}/${_pkgname}" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
# Invoked only by the package's Remove/PreTransaction ALPM hook.
|
||||
set -eu
|
||||
|
||||
# systemd's configuration parser throws the whitespace around an assignment away
|
||||
# (parse_line() runs both halves through strstrip()), so a drop-in written as
|
||||
# ExecStart = /home/alice/build/omawake daemon
|
||||
# picks the executable just as surely as the unspaced form does.
|
||||
readonly execstart_assignment='^[[:space:]]*ExecStart[[:space:]]*='
|
||||
|
||||
owned_unit() {
|
||||
local commands
|
||||
[[ -f $1 && ! -L $1 ]] || return 1
|
||||
# Only a unit in the very shape the application generates is ever deleted;
|
||||
# anything else, however it is spaced, stays somebody's own file.
|
||||
commands=$(grep '^ExecStart=' "$1") || return 1
|
||||
[[ $commands != *$'\n'* ]] || return 1
|
||||
grep -Eq "^ExecStart=\"?/usr/bin/$2\"?([[:space:]]|$)" <<< "$commands"
|
||||
}
|
||||
|
||||
# Decode systemd's shell_maybe_quote() output, including cescape_char() escapes.
|
||||
# Never evaluate manager-controlled values as shell syntax.
|
||||
unquote_manager_value() {
|
||||
local text=$1 decoded= character octal
|
||||
if [[ $text != '$'* ]]; then
|
||||
unquoted_value=$text
|
||||
return 0
|
||||
fi
|
||||
[[ $text == \$\'*\' ]] || return 1
|
||||
text=${text:2:${#text}-3}
|
||||
while [[ -n $text ]]; do
|
||||
if [[ ${text:0:1} != \\ ]]; then
|
||||
decoded+=${text:0:1}
|
||||
text=${text:1}
|
||||
continue
|
||||
fi
|
||||
case ${text:1:1} in
|
||||
\\ | "'") decoded+=${text:1:1} ;;
|
||||
a | b | f | n | r | t | v)
|
||||
printf -v character '%b' "\\${text:1:1}"
|
||||
decoded+=$character
|
||||
;;
|
||||
[0-3])
|
||||
octal=${text:1:3}
|
||||
[[ $octal =~ ^[0-3][0-7]{2}$ && $octal != 000 ]] || return 1
|
||||
printf -v character '%b' "\\0$octal"
|
||||
decoded+=$character
|
||||
text=${text:4}
|
||||
continue
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
text=${text:2}
|
||||
done
|
||||
unquoted_value=$decoded
|
||||
}
|
||||
|
||||
remove_for_user() {
|
||||
local app=$1 user_home=$2 runtime=$3 config="$2/.config" unit="$1.service"
|
||||
local online=false effective path target manager_environment load_state
|
||||
if [[ -S $runtime/bus || -S $runtime/systemd/private ]]; then
|
||||
online=true
|
||||
# Do not evaluate shell syntax from a user manager's environment.
|
||||
manager_environment=$(systemctl --user show-environment) || return 1
|
||||
while IFS= read -r line; do
|
||||
case $line in
|
||||
XDG_CONFIG_HOME=*)
|
||||
if ! unquote_manager_value "${line#XDG_CONFIG_HOME=}"; then
|
||||
echo ":: Cannot tell which directory $user_home's manager reads $unit from; removal aborted." >&2
|
||||
return 1
|
||||
fi
|
||||
# An XDG_CONFIG_HOME that is not an absolute path is no setting at all:
|
||||
# the manager itself falls back to the home's .config directory then.
|
||||
if [[ $unquoted_value == /* ]]; then config=$unquoted_value; fi
|
||||
;;
|
||||
esac
|
||||
done <<< "$manager_environment"
|
||||
effective=$(systemctl --user show "$unit" --property=ExecStart --value) || return 1
|
||||
if [[ -n $effective && $effective != *"path=/usr/bin/$app ;"* ]]; then
|
||||
echo ":: Preserving $unit for $user_home: it uses another executable."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
path="$config/systemd/user/$unit"
|
||||
if ! $online && grep -qs "$execstart_assignment" "$path.d/"*.conf; then
|
||||
echo ":: Preserving offline service with an executable override: $path."
|
||||
return 0
|
||||
fi
|
||||
if [[ -e $path || -L $path ]]; then
|
||||
if ! owned_unit "$path" "$app"; then
|
||||
echo ":: Preserving custom or masked unit $path."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if $online; then
|
||||
# Stop first, and fail the package transaction if stopping fails.
|
||||
load_state=$(systemctl --user show "$unit" --property=LoadState --value) || return 1
|
||||
if [[ $load_state != not-found ]]; then
|
||||
systemctl --user stop "$unit" || return 1
|
||||
# A unit that had failed stays failed once it is stopped, which is the one
|
||||
# state systemd will reset. For every other state it answers that the unit
|
||||
# is not loaded and exits non-zero, so the reset is asked for only where it
|
||||
# applies: a healthy unit leaving the transaction never aborts a removal.
|
||||
if [[ $(systemctl --user show "$unit" --property=ActiveState --value) == failed ]]; then
|
||||
systemctl --user reset-failed "$unit" || return 1
|
||||
fi
|
||||
# Disabling also removes enablement links outside the normal target.
|
||||
systemctl --user disable "$unit" || return 1
|
||||
fi
|
||||
fi
|
||||
# Offline users have no bus. Remove only exact enablement links for this unit.
|
||||
# All filesystem operations run as the owning user, never as pacman's root.
|
||||
for target in "$config/systemd/user/"*.wants/"$unit" "$config/systemd/user/"*.requires/"$unit"; do
|
||||
[[ -L $target ]] || continue
|
||||
case $(realpath -m -- "$target") in
|
||||
"$path"|"/usr/lib/systemd/user/$unit") rm -- "$target" ;;
|
||||
esac
|
||||
done
|
||||
if owned_unit "$path" "$app"; then rm -- "$path"; fi
|
||||
if $online; then systemctl --user daemon-reload || return 1; fi
|
||||
}
|
||||
|
||||
if [[ ${1-} == --user ]]; then
|
||||
shift
|
||||
case ${1-} in omawake|omaspeak) ;; *) exit 2 ;; esac
|
||||
[[ $# == 3 ]] || exit 2
|
||||
remove_for_user "$@"
|
||||
exit
|
||||
fi
|
||||
|
||||
[[ $# == 1 ]] || exit 2
|
||||
case $1 in omawake|omaspeak) ;; *) exit 2 ;; esac
|
||||
app=$1
|
||||
result=0
|
||||
# Include logged-out users as well as active/lingering user managers.
|
||||
accounts=$(getent passwd) || exit 1
|
||||
while IFS=: read -r account _ user_id _ _ user_home _; do
|
||||
[[ $user_home == /* ]] || continue
|
||||
runtime="/run/user/$user_id"
|
||||
if [[ ! -d $user_home/.config/systemd/user && ! -S $runtime/bus && ! -S $runtime/systemd/private ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! runuser -u "$account" -- env -u XDG_CONFIG_HOME \
|
||||
XDG_RUNTIME_DIR="$runtime" DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus" \
|
||||
"$0" --user "$app" "$user_home" "$runtime"; then
|
||||
echo ":: Could not clean up $app for $account; removal aborted. Stop/remove the user service and retry." >&2
|
||||
result=1
|
||||
fi
|
||||
done <<< "$accounts"
|
||||
exit "$result"
|
||||
@@ -0,0 +1,10 @@
|
||||
[Trigger]
|
||||
Operation = Remove
|
||||
Type = Package
|
||||
Target = omaspeak-bin
|
||||
|
||||
[Action]
|
||||
Description = Stop and remove omaspeak user services before package removal
|
||||
When = PreTransaction
|
||||
Exec = /usr/lib/omaspeak/package-remove omaspeak
|
||||
AbortOnFail
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
pkgname=omawake-bin
|
||||
_pkgname=${pkgname%-bin}
|
||||
pkgver=0.0.2
|
||||
_upstream_ver=0.0.2
|
||||
pkgrel=1
|
||||
pkgver=0.0.3
|
||||
_upstream_ver=0.0.3
|
||||
pkgrel=4
|
||||
pkgdesc='Configurable local wake-word daemon (pre-built binary)'
|
||||
arch=('x86_64' 'aarch64')
|
||||
url='https://github.com/jacob-vincent-mink/omawake'
|
||||
@@ -27,13 +27,21 @@ conflicts=("${_pkgname}")
|
||||
install="${pkgname}.install"
|
||||
options=('!strip' '!debug')
|
||||
|
||||
source=('package-remove' 'remove-user-services.hook')
|
||||
sha256sums=('f1b527448529b45fee2f96b4c0a19b11087377c0ac9f43842bab0d04fcfd3b9f'
|
||||
'a0bb2e9de807bdb2cc8d0076eac3c21555c910eb8baa06acfba18fea716b9014')
|
||||
|
||||
source_x86_64=("${_pkgname}-${_upstream_ver}-linux-x86_64.tar.xz::${url}/releases/download/v${_upstream_ver}/${_pkgname}-${_upstream_ver}-linux-x86_64.tar.xz")
|
||||
source_aarch64=("${_pkgname}-${_upstream_ver}-linux-aarch64.tar.xz::${url}/releases/download/v${_upstream_ver}/${_pkgname}-${_upstream_ver}-linux-aarch64.tar.xz")
|
||||
|
||||
sha256sums_x86_64=('94f0677eb497babd351cb154e9adf057e4b796efe0241d809a7f7cf84742a515')
|
||||
sha256sums_aarch64=('acb359b21bbe4909b13c18a0de63f8106c6b254943074b57d8fd70af41421659')
|
||||
sha256sums_x86_64=('fa374341f60760b04c9a97d76f7ad2679463f5b2b673ee6d9c1ceee97d3f0669')
|
||||
sha256sums_aarch64=('384eb11872c873a33332acf51f567dc4d4e327e57563b61056e4d0aa7fe470eb')
|
||||
|
||||
package() {
|
||||
install -Dm755 "${srcdir}/package-remove" "${pkgdir}/usr/lib/${_pkgname}/package-remove"
|
||||
install -Dm644 "${srcdir}/remove-user-services.hook" \
|
||||
"${pkgdir}/usr/share/libalpm/hooks/30-${_pkgname}-remove-user-services.hook"
|
||||
|
||||
local release_root="${srcdir}/${_pkgname}-${_upstream_ver}-linux-${CARCH}"
|
||||
|
||||
install -Dm755 "${release_root}/${_pkgname}" "${pkgdir}/usr/bin/${_pkgname}"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
# Invoked only by the package's Remove/PreTransaction ALPM hook.
|
||||
set -eu
|
||||
|
||||
# systemd's configuration parser throws the whitespace around an assignment away
|
||||
# (parse_line() runs both halves through strstrip()), so a drop-in written as
|
||||
# ExecStart = /home/alice/build/omawake daemon
|
||||
# picks the executable just as surely as the unspaced form does.
|
||||
readonly execstart_assignment='^[[:space:]]*ExecStart[[:space:]]*='
|
||||
|
||||
owned_unit() {
|
||||
local commands
|
||||
[[ -f $1 && ! -L $1 ]] || return 1
|
||||
# Only a unit in the very shape the application generates is ever deleted;
|
||||
# anything else, however it is spaced, stays somebody's own file.
|
||||
commands=$(grep '^ExecStart=' "$1") || return 1
|
||||
[[ $commands != *$'\n'* ]] || return 1
|
||||
grep -Eq "^ExecStart=\"?/usr/bin/$2\"?([[:space:]]|$)" <<< "$commands"
|
||||
}
|
||||
|
||||
# Decode systemd's shell_maybe_quote() output, including cescape_char() escapes.
|
||||
# Never evaluate manager-controlled values as shell syntax.
|
||||
unquote_manager_value() {
|
||||
local text=$1 decoded= character octal
|
||||
if [[ $text != '$'* ]]; then
|
||||
unquoted_value=$text
|
||||
return 0
|
||||
fi
|
||||
[[ $text == \$\'*\' ]] || return 1
|
||||
text=${text:2:${#text}-3}
|
||||
while [[ -n $text ]]; do
|
||||
if [[ ${text:0:1} != \\ ]]; then
|
||||
decoded+=${text:0:1}
|
||||
text=${text:1}
|
||||
continue
|
||||
fi
|
||||
case ${text:1:1} in
|
||||
\\ | "'") decoded+=${text:1:1} ;;
|
||||
a | b | f | n | r | t | v)
|
||||
printf -v character '%b' "\\${text:1:1}"
|
||||
decoded+=$character
|
||||
;;
|
||||
[0-3])
|
||||
octal=${text:1:3}
|
||||
[[ $octal =~ ^[0-3][0-7]{2}$ && $octal != 000 ]] || return 1
|
||||
printf -v character '%b' "\\0$octal"
|
||||
decoded+=$character
|
||||
text=${text:4}
|
||||
continue
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
text=${text:2}
|
||||
done
|
||||
unquoted_value=$decoded
|
||||
}
|
||||
|
||||
remove_for_user() {
|
||||
local app=$1 user_home=$2 runtime=$3 config="$2/.config" unit="$1.service"
|
||||
local online=false effective path target manager_environment load_state
|
||||
if [[ -S $runtime/bus || -S $runtime/systemd/private ]]; then
|
||||
online=true
|
||||
# Do not evaluate shell syntax from a user manager's environment.
|
||||
manager_environment=$(systemctl --user show-environment) || return 1
|
||||
while IFS= read -r line; do
|
||||
case $line in
|
||||
XDG_CONFIG_HOME=*)
|
||||
if ! unquote_manager_value "${line#XDG_CONFIG_HOME=}"; then
|
||||
echo ":: Cannot tell which directory $user_home's manager reads $unit from; removal aborted." >&2
|
||||
return 1
|
||||
fi
|
||||
# An XDG_CONFIG_HOME that is not an absolute path is no setting at all:
|
||||
# the manager itself falls back to the home's .config directory then.
|
||||
if [[ $unquoted_value == /* ]]; then config=$unquoted_value; fi
|
||||
;;
|
||||
esac
|
||||
done <<< "$manager_environment"
|
||||
effective=$(systemctl --user show "$unit" --property=ExecStart --value) || return 1
|
||||
if [[ -n $effective && $effective != *"path=/usr/bin/$app ;"* ]]; then
|
||||
echo ":: Preserving $unit for $user_home: it uses another executable."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
path="$config/systemd/user/$unit"
|
||||
if ! $online && grep -qs "$execstart_assignment" "$path.d/"*.conf; then
|
||||
echo ":: Preserving offline service with an executable override: $path."
|
||||
return 0
|
||||
fi
|
||||
if [[ -e $path || -L $path ]]; then
|
||||
if ! owned_unit "$path" "$app"; then
|
||||
echo ":: Preserving custom or masked unit $path."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if $online; then
|
||||
# Stop first, and fail the package transaction if stopping fails.
|
||||
load_state=$(systemctl --user show "$unit" --property=LoadState --value) || return 1
|
||||
if [[ $load_state != not-found ]]; then
|
||||
systemctl --user stop "$unit" || return 1
|
||||
# A unit that had failed stays failed once it is stopped, which is the one
|
||||
# state systemd will reset. For every other state it answers that the unit
|
||||
# is not loaded and exits non-zero, so the reset is asked for only where it
|
||||
# applies: a healthy unit leaving the transaction never aborts a removal.
|
||||
if [[ $(systemctl --user show "$unit" --property=ActiveState --value) == failed ]]; then
|
||||
systemctl --user reset-failed "$unit" || return 1
|
||||
fi
|
||||
# Disabling also removes enablement links outside the normal target.
|
||||
systemctl --user disable "$unit" || return 1
|
||||
fi
|
||||
fi
|
||||
# Offline users have no bus. Remove only exact enablement links for this unit.
|
||||
# All filesystem operations run as the owning user, never as pacman's root.
|
||||
for target in "$config/systemd/user/"*.wants/"$unit" "$config/systemd/user/"*.requires/"$unit"; do
|
||||
[[ -L $target ]] || continue
|
||||
case $(realpath -m -- "$target") in
|
||||
"$path"|"/usr/lib/systemd/user/$unit") rm -- "$target" ;;
|
||||
esac
|
||||
done
|
||||
if owned_unit "$path" "$app"; then rm -- "$path"; fi
|
||||
if $online; then systemctl --user daemon-reload || return 1; fi
|
||||
}
|
||||
|
||||
if [[ ${1-} == --user ]]; then
|
||||
shift
|
||||
case ${1-} in omawake|omaspeak) ;; *) exit 2 ;; esac
|
||||
[[ $# == 3 ]] || exit 2
|
||||
remove_for_user "$@"
|
||||
exit
|
||||
fi
|
||||
|
||||
[[ $# == 1 ]] || exit 2
|
||||
case $1 in omawake|omaspeak) ;; *) exit 2 ;; esac
|
||||
app=$1
|
||||
result=0
|
||||
# Include logged-out users as well as active/lingering user managers.
|
||||
accounts=$(getent passwd) || exit 1
|
||||
while IFS=: read -r account _ user_id _ _ user_home _; do
|
||||
[[ $user_home == /* ]] || continue
|
||||
runtime="/run/user/$user_id"
|
||||
if [[ ! -d $user_home/.config/systemd/user && ! -S $runtime/bus && ! -S $runtime/systemd/private ]]; then
|
||||
continue
|
||||
fi
|
||||
if ! runuser -u "$account" -- env -u XDG_CONFIG_HOME \
|
||||
XDG_RUNTIME_DIR="$runtime" DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime/bus" \
|
||||
"$0" --user "$app" "$user_home" "$runtime"; then
|
||||
echo ":: Could not clean up $app for $account; removal aborted. Stop/remove the user service and retry." >&2
|
||||
result=1
|
||||
fi
|
||||
done <<< "$accounts"
|
||||
exit "$result"
|
||||
@@ -0,0 +1,10 @@
|
||||
[Trigger]
|
||||
Operation = Remove
|
||||
Type = Package
|
||||
Target = omawake-bin
|
||||
|
||||
[Action]
|
||||
Description = Stop and remove omawake user services before package removal
|
||||
When = PreTransaction
|
||||
Exec = /usr/lib/omawake/package-remove omawake
|
||||
AbortOnFail
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Removal regression fixtures. No real systemd manager, user home or package is touched."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# Characters that make systemd's shell_maybe_quote() quote a value, a copy of
|
||||
# SHELL_NEED_ESCAPE, GLOB_CHARS and the rest of SHELL_NEED_QUOTES in escape.h.
|
||||
SHELL_NEED_QUOTES = '"\\`$*?[]' + "'()<>|&;!"
|
||||
|
||||
|
||||
def systemd_environment_value(value):
|
||||
r"""Return VALUE as ``systemctl show-environment`` would print it.
|
||||
|
||||
print_variable() in systemctl-set-environment.c hands every value to
|
||||
shell_maybe_quote(SHELL_ESCAPE_POSIX) quotes special values and uses
|
||||
cescape_char() for control bytes.
|
||||
"""
|
||||
if not any(c in SHELL_NEED_QUOTES or c.isspace() or ord(c) < 0x20 or c == "\x7f"
|
||||
for c in value):
|
||||
return value
|
||||
escapes = dict(zip("\a\b\f\n\r\t\v\\'", (r"\a", r"\b", r"\f", r"\n", r"\r", r"\t", r"\v", r"\\", r"\'")))
|
||||
return "$'" + "".join(escapes.get(c, f"\\{ord(c):03o}" if ord(c) < 0x20 or c == "\x7f" else c)
|
||||
for c in value) + "'"
|
||||
|
||||
|
||||
class Removal(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="oma-removal-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.home = self.root / "home"
|
||||
self.runtime = self.root / "runtime"
|
||||
self.units = self.home / ".config/systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
self.runtime.mkdir()
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.log = self.root / "calls"
|
||||
self.env = dict(os.environ, PATH=f"{self.bin}:{os.environ['PATH']}", CALLS=str(self.log))
|
||||
self.executable("systemctl", '''#!/bin/bash
|
||||
printf '%s\\n' "$*" >> "$CALLS"
|
||||
case "$*" in
|
||||
*show-environment*) [[ -z ${MANAGER_FAIL-} ]] || exit 1
|
||||
# print_variable() prints every value the way a shell would read it, so the
|
||||
# fixture, not this stub, decides how the value is quoted.
|
||||
echo "XDG_CONFIG_HOME=${CONFIG_HOME_RAW-${CONFIG_HOME-}}" ;;
|
||||
*property=ExecStart*) echo "${EFFECTIVE-}" ;;
|
||||
*property=LoadState*) echo "${LOAD_STATE-loaded}" ;;
|
||||
# A unit that failed stays failed after it is stopped, and systemd refuses
|
||||
# reset-failed for every other state, reporting the unit as not loaded.
|
||||
*property=ActiveState*) echo "${ACTIVE_STATE-inactive}" ;;
|
||||
*" reset-failed "*) [[ ${ACTIVE_STATE-inactive} == failed && -z ${RESET_FAIL-} ]] || {
|
||||
printf 'Failed to reset failed state of unit: Unit is not loaded.\n' >&2; exit 1; } ;;
|
||||
*" stop "*) [[ -z ${STOP_FAIL-} ]] || exit 1 ;;
|
||||
esac
|
||||
''')
|
||||
|
||||
def executable(self, name, source):
|
||||
path = self.bin / name
|
||||
path.write_text(source)
|
||||
path.chmod(0o755)
|
||||
|
||||
def online(self):
|
||||
sock = socket.socket(socket.AF_UNIX)
|
||||
sock.bind(str(self.runtime / "bus"))
|
||||
self.addCleanup(sock.close)
|
||||
|
||||
def install(self, app, binary=None):
|
||||
unit = self.units / f"{app}.service"
|
||||
unit.write_text(f'[Service]\nExecStart="{binary or "/usr/bin/" + app}" --config "{self.home}/config.toml" daemon\n')
|
||||
target = self.units / "graphical-session.target.wants"
|
||||
target.mkdir(exist_ok=True)
|
||||
link = target / unit.name
|
||||
link.symlink_to(f"../{unit.name}")
|
||||
return unit, link
|
||||
|
||||
def run_remove(self, app):
|
||||
self.log.unlink(missing_ok=True) # Every run is judged on its own calls.
|
||||
return subprocess.run(["bash", str(ROOT / f"pkgbuilds/{app}-bin/package-remove"),
|
||||
"--user", app, str(self.home), str(self.runtime)],
|
||||
env=self.env, text=True, capture_output=True)
|
||||
|
||||
def test_logged_out_users_and_data_preservation(self):
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
config = self.home / f"{app}.toml"
|
||||
config.write_text("keep settings and models")
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertEqual(config.read_text(), "keep settings and models")
|
||||
self.assertFalse(self.log.exists(), "offline cleanup contacted systemd")
|
||||
|
||||
def test_active_unit_is_stopped_before_removing_it(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
self.env["EFFECTIVE"] = f"{{ path=/usr/bin/{app} ; argv[]=/usr/bin/{app} daemon ; }}"
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
calls = self.log.read_text().splitlines()
|
||||
self.assertLess(calls.index(f"--user stop {app}.service"), calls.index(f"--user disable {app}.service"))
|
||||
self.assertEqual(calls[-1], "--user daemon-reload")
|
||||
|
||||
def test_failed_stop_prevents_unit_deletion_and_fails_hook(self):
|
||||
self.online()
|
||||
unit, link = self.install("omawake")
|
||||
self.env.update(STOP_FAIL="1", EFFECTIVE="{ path=/usr/bin/omawake ; }")
|
||||
result = self.run_remove("omawake")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertNotIn("disable", self.log.read_text())
|
||||
|
||||
def test_reset_failed_is_requested_only_for_a_unit_that_failed(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
self.env["EFFECTIVE"] = f"{{ path=/usr/bin/{app} ; argv[]=/usr/bin/{app} daemon ; }}"
|
||||
# A loaded unit that never failed is not failed, and asking systemd to
|
||||
# reset it fails with "Unit is not loaded": that must not abort removal.
|
||||
self.env["ACTIVE_STATE"] = "active"
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = [call for call in self.log.read_text().splitlines() if call.startswith("--user")]
|
||||
self.assertNotIn(f"--user reset-failed {app}.service", calls)
|
||||
# Reading the manager's state is welcome; the only changes asked for
|
||||
# are the stop, the disable and the reload that follow them.
|
||||
self.assertEqual([call for call in calls
|
||||
if "show-environment" not in call and "--property=" not in call],
|
||||
[f"--user stop {app}.service",
|
||||
f"--user disable {app}.service", "--user daemon-reload"])
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
|
||||
# A unit that failed does keep that state once stopped, and there the
|
||||
# reset belongs between stopping the service and disabling the unit.
|
||||
unit, link = self.install(app)
|
||||
self.env["ACTIVE_STATE"] = "failed"
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
calls = self.log.read_text().splitlines()
|
||||
reset = f"--user reset-failed {app}.service"
|
||||
self.assertIn(reset, calls)
|
||||
self.assertLess(calls.index(f"--user stop {app}.service"), calls.index(reset))
|
||||
self.assertLess(calls.index(reset), calls.index(f"--user disable {app}.service"))
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
del self.env["ACTIVE_STATE"]
|
||||
|
||||
def test_reset_refused_by_a_healthy_manager_still_fails_the_transaction(self):
|
||||
self.online()
|
||||
unit, link = self.install("omaspeak")
|
||||
self.env.update(EFFECTIVE="{ path=/usr/bin/omaspeak ; }",
|
||||
ACTIVE_STATE="failed", RESET_FAIL="1")
|
||||
result = self.run_remove("omaspeak")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
|
||||
def test_custom_build_and_mask_are_preserved(self):
|
||||
unit, link = self.install("omawake", "/home/user/dev/omawake")
|
||||
self.assertEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
unit.unlink()
|
||||
unit.symlink_to("/dev/null")
|
||||
self.assertEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertTrue(unit.is_symlink())
|
||||
self.assertFalse(self.log.exists())
|
||||
|
||||
def test_effective_override_and_missing_online_unit(self):
|
||||
self.online()
|
||||
unit, _ = self.install("omaspeak")
|
||||
self.env["EFFECTIVE"] = "{ path=/home/user/development/omaspeak ; }"
|
||||
self.assertEqual(self.run_remove("omaspeak").returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertNotIn(" stop ", self.log.read_text())
|
||||
unit.unlink()
|
||||
self.env.update(EFFECTIVE="", LOAD_STATE="not-found")
|
||||
self.assertEqual(self.run_remove("omaspeak").returncode, 0)
|
||||
self.assertNotIn(" stop ", self.log.read_text())
|
||||
|
||||
def test_manager_config_home_and_unavailable_manager(self):
|
||||
self.online()
|
||||
default = self.units
|
||||
self.units = self.home / "custom-config/systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
unit, link = self.install("omawake")
|
||||
self.env.update(CONFIG_HOME=str(self.home / "custom-config"), MANAGER_FAIL="1")
|
||||
self.assertNotEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
del self.env["MANAGER_FAIL"]
|
||||
self.assertEqual(self.run_remove("omawake").returncode, 0)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertTrue(default.exists())
|
||||
|
||||
def test_root_dispatch_drops_privileges_and_propagates_failure(self):
|
||||
passwd = f"fixture:x:12345:12345::{self.home}:/bin/bash"
|
||||
self.executable("getent", f"#!/bin/sh\nprintf '%s\\n' '{passwd}'\n")
|
||||
self.executable("runuser", '#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALLS"\nexit 1\n')
|
||||
result = subprocess.run(["bash", str(ROOT / "pkgbuilds/omawake-bin/package-remove"), "omawake"], env=self.env, capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("-u fixture -- env", self.log.read_text())
|
||||
self.assertIn("--user omawake", self.log.read_text())
|
||||
|
||||
def test_offline_executable_overrides_are_preserved(self):
|
||||
for app in ("omawake", "omaspeak"):
|
||||
for spacing in ("ExecStart=\nExecStart={command}",
|
||||
# systemd's parser throws the whitespace around an
|
||||
# assignment away, so both of these spellings still
|
||||
# name a development build, exactly as the first does.
|
||||
"ExecStart =\nExecStart = {command}",
|
||||
"\tExecStart\t=\t{command}"):
|
||||
unit, link = self.install(app)
|
||||
dropins = Path(str(unit) + ".d")
|
||||
dropins.mkdir(exist_ok=True)
|
||||
(dropins / "override.conf").write_text("[Service]\n" + spacing.format(
|
||||
command=f"/home/user/build/{app} daemon") + "\n")
|
||||
try:
|
||||
with self.subTest(app=app, spacing=spacing):
|
||||
self.assertEqual(self.run_remove(app).returncode, 0)
|
||||
self.assertTrue(unit.exists(), "removed a service with an override")
|
||||
self.assertTrue(link.is_symlink(), "unlinked a service with an override")
|
||||
finally:
|
||||
unit.unlink(missing_ok=True)
|
||||
link.unlink(missing_ok=True)
|
||||
self.assertFalse(self.log.exists(), "offline cleanup contacted systemd")
|
||||
|
||||
def test_shell_quoted_manager_config_home_is_resolved(self):
|
||||
self.online()
|
||||
default_units = self.units
|
||||
for app in ("omawake", "omaspeak"):
|
||||
config_home = self.home / f"{app} custom's \\ config"
|
||||
self.units = config_home / "systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
unit, link = self.install(app)
|
||||
# A unit in the directory the manager reads nothing from is no unit of
|
||||
# the manager's, and the helper has no business reaching for it.
|
||||
stray = default_units / f"{app}.service"
|
||||
stray.write_text(f'[Service]\nExecStart="/usr/bin/{app}" daemon\n')
|
||||
printed = self.env["CONFIG_HOME_RAW"] = systemd_environment_value(str(config_home))
|
||||
with self.subTest(app=app, printed=printed):
|
||||
self.assertTrue(printed.startswith("$'") and printed.endswith("'"),
|
||||
"a path of spaces is not what a plain value looks like")
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
self.assertTrue(stray.is_file(), "guessed at a directory no manager reads")
|
||||
del self.env["CONFIG_HOME_RAW"]
|
||||
|
||||
def test_control_character_manager_config_home_is_resolved(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
for suffix in ("tab\tpath", "newline\npath\n", "\a\b\f\r\v", "\x01\x1b\x7f"):
|
||||
config_home = self.home / (app + suffix)
|
||||
self.units = config_home / "systemd/user"
|
||||
self.units.mkdir(parents=True)
|
||||
unit, link = self.install(app)
|
||||
self.env["CONFIG_HOME_RAW"] = systemd_environment_value(str(config_home))
|
||||
with self.subTest(app=app, suffix=suffix):
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
|
||||
def test_unreadable_manager_config_home_aborts_the_cleanup(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
# Truncated output must not make cleanup guess at a directory.
|
||||
self.env["CONFIG_HOME_RAW"] = "$'" + str(self.home / f"broken {app} config")
|
||||
with self.subTest(app=app):
|
||||
result = self.run_remove(app)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(unit.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertNotIn(" stop ", self.log.read_text())
|
||||
del self.env["CONFIG_HOME_RAW"]
|
||||
|
||||
def test_relative_manager_config_home_keeps_the_default_directory(self):
|
||||
self.online()
|
||||
for app in ("omawake", "omaspeak"):
|
||||
unit, link = self.install(app)
|
||||
# An XDG_CONFIG_HOME that is not absolute is no setting at all: the
|
||||
# manager itself reads the home's .config directory then.
|
||||
self.env["CONFIG_HOME_RAW"] = systemd_environment_value(f"relative {app} config")
|
||||
with self.subTest(app=app):
|
||||
result = self.run_remove(app)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(unit.exists())
|
||||
self.assertFalse(link.is_symlink())
|
||||
del self.env["CONFIG_HOME_RAW"]
|
||||
|
||||
def test_packaging_installs_hooks_and_helpers(self):
|
||||
import shutil
|
||||
for app, version in (("omawake", "0.0.3"), ("omaspeak", "0.0.3")):
|
||||
source = self.root / app / "src"
|
||||
package = self.root / app / "pkg"
|
||||
release = source / f"{app}-{version}-linux-x86_64"
|
||||
release.mkdir(parents=True)
|
||||
for path in [app, "lib/libaudiocpp.so.0.1.0", f"packaging/systemd/{app}.service",
|
||||
"README.md", "INSTALL.md", "ACCELERATOR_SETUP.md", "CHANGELOG.md",
|
||||
"RELEASE_NOTES.md", "DEMO.md", "RUNTIME.md", "config.example.toml",
|
||||
"licenses/LICENSE", "assets/fixture", "benchmarks/fixture"]:
|
||||
target = release / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("fixture")
|
||||
directory = ROOT / f"pkgbuilds/{app}-bin"
|
||||
for name in ("package-remove", "remove-user-services.hook"):
|
||||
shutil.copyfile(directory / name, source / name)
|
||||
env = dict(self.env, srcdir=str(source), pkgdir=str(package), CARCH="x86_64")
|
||||
result = subprocess.run(["bash", "-c", 'source "$1"; package', "package-fixture", str(directory / "PKGBUILD")], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
helper = package / f"usr/lib/{app}/package-remove"
|
||||
self.assertEqual(helper.read_bytes(), (directory / "package-remove").read_bytes())
|
||||
self.assertEqual(helper.stat().st_mode & 0o777, 0o755)
|
||||
self.assertTrue((package / f"usr/share/libalpm/hooks/30-{app}-remove-user-services.hook").exists())
|
||||
|
||||
def test_hook_contract_and_package_release(self):
|
||||
scripts = []
|
||||
for app in ("omawake", "omaspeak"):
|
||||
directory = ROOT / f"pkgbuilds/{app}-bin"
|
||||
hook = (directory / "remove-user-services.hook").read_text()
|
||||
self.assertIn("Operation = Remove", hook)
|
||||
self.assertNotIn("Operation = Upgrade", hook)
|
||||
self.assertIn("When = PreTransaction", hook)
|
||||
self.assertIn("AbortOnFail", hook)
|
||||
self.assertIn(f"Exec = /usr/lib/{app}/package-remove {app}", hook)
|
||||
self.assertIn("pkgrel=4", (directory / "PKGBUILD").read_text())
|
||||
scripts.append((directory / "package-remove").read_bytes())
|
||||
self.assertEqual(*scripts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user