testsAndMisc/linux_configuration/scripts/periodic_background/lib/tests/services_harness.sh
Krzysztof kuhy Rudnicki 08f3c764b2
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
check_and_enable_services: a warning must never mask an error
Eight checks assigned status="warning" unconditionally, so a check running
later in the same function overwrote the status="error" an earlier one had
set. The five other sites in this family already guard the assignment with
`if [[ $status != "error" ]]`, so this was an inconsistency, not a design.

Only half of it was load-bearing, and the two halves fail differently:

  services_units.sh (4 sites) reports through report_and_fix, which repairs
  ONLY on "error". A downgrade there meant no repair ran at all. The is-active
  check is the LAST status assignment in each of the three functions, so it
  could mask any error above it -- in check_periodic_systems, three separate
  is-enabled checks. A timer that was both disabled and inactive, which is the
  ordinary shape of a broken timer, was reported and then left broken.

  services_hosts.sh (4 sites) gates its own repair on `status != "ok"`, so the
  repair still ran. There the bug printed a real error as a yellow [!] warning
  and understated the summary row.

Demonstrated rather than argued, against a simulated broken machine (timer
disabled AND inactive, unit files present):

  without the guards:  status=warning, repair ran 0 times
  with the guards:     status=error,   repair ran 1 time

The seven assertions that previously pinned the broken behaviour now assert
the correct behaviour; they were rewritten first and failed, then the guards
made them pass. Two further cases cover the masking shape directly: a broken
guard-lib instance must still be repaired when a later check warns.

`--status --dry-run` output is still byte-identical to rev 6691cb26, because
this machine has no disabled timer and no missing pacman hook -- the buggy
path is never taken here, which is precisely why it went unnoticed. The A/B
above is the evidence the live diff cannot provide.

222 assertions across 10 files. services_units.sh holds 100% coverage;
services_hosts.sh rises from 86.05% to 93.02%. test_services_hosts.sh needed
splitting twice to stay under the 250-line cap (hosts_fix, resolved), and
stage_hosts_ok moved into the harness so both new files can use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3PzGc75gBcdxP3PkHXGcV
2026-08-21 14:16:01 +02:00

251 lines
8.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# lib/tests/services_harness.sh — shared fixture for the check_and_enable_services
# split.
#
# Sourced, not executed. Every command the checks reach for — systemctl,
# guardctl, lsattr, chattr, logger, sudo, find, wc — is a PATH shim recording
# its invocation into $DEV/calls, so a test asserts on what the code TRIED to
# do rather than on the state of this machine. Nothing here touches a real unit,
# a real /etc file, or a real installer.
#
# The paths the checks probe (/usr/bin/pacman, /etc/hosts, ...) are prefixed
# with $SYSROOT in every lib, so this harness points SERVICES_ROOT at a fixture
# tree under the tmpdir. A test declares "pretend /etc/hosts has 200 lines" by
# actually creating $SYSROOT/etc/hosts with 200 lines -- the checks then run
# their real logic against a filesystem this process owns.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck source=services_assert.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/services_assert.sh"
# --- fake external tools ----------------------------------------------------
# systemctl: `is-enabled`/`is-active <unit>` succeed only when $DEV/enabled or
# $DEV/active lists the unit. Every other subcommand (restart, ...) just
# records. `--user --machine=...` is the user_systemctl shape and is answered
# from the same two files, prefixed "user:".
cat >"${FAKE_BIN}/systemctl" <<'SYSTEMCTLSHIM'
#!/usr/bin/env bash
set -euo pipefail
DEV="${SERVICES_TEST_DEV}"
printf '%s\n' "systemctl $*" >>"${DEV}/calls"
prefix=""
args=()
for a in "$@"; do
case "$a" in
--user) prefix="user:" ;;
--machine=*) ;;
*) args+=("$a") ;;
esac
done
verb="${args[0]:-}"
unit="${args[1]:-}"
case "$verb" in
is-enabled) grep -qxF "${prefix}${unit}" "${DEV}/enabled" 2>/dev/null ;;
is-active) grep -qxF "${prefix}${unit}" "${DEV}/active" 2>/dev/null ;;
*) exit 0 ;;
esac
SYSTEMCTLSHIM
chmod +x "${FAKE_BIN}/systemctl"
# guardctl file-guard status <name>: prints the two lines
# guard_lib_instance_healthy greps for, but only for instances listed in
# $DEV/guard_healthy. An instance in $DEV/guard_degraded prints a status whose
# path unit is inactive; anything else exits 1 (unregistered).
cat >"${FAKE_BIN}/guardctl" <<'GUARDCTLSHIM'
#!/usr/bin/env bash
set -euo pipefail
DEV="${SERVICES_TEST_DEV}"
printf '%s\n' "guardctl $*" >>"${DEV}/calls"
name="${3:-}"
if grep -qxF "$name" "${DEV}/guard_healthy" 2>/dev/null; then
printf 'path unit: active\ntarget attrs: ----i---------e---- /etc/%s\n' "$name"
exit 0
fi
if grep -qxF "$name" "${DEV}/guard_degraded" 2>/dev/null; then
printf 'path unit: inactive\ntarget attrs: ------------------ /etc/%s\n' "$name"
exit 0
fi
exit 1
GUARDCTLSHIM
chmod +x "${FAKE_BIN}/guardctl"
# lsattr: prints an attribute string for paths listed in $DEV/immutable,
# otherwise the all-dashes "no attributes" form.
cat >"${FAKE_BIN}/lsattr" <<'LSATTRSHIM'
#!/usr/bin/env bash
set -euo pipefail
DEV="${SERVICES_TEST_DEV}"
target="${1:-}"
if grep -qxF "$target" "${DEV}/immutable" 2>/dev/null; then
printf '%s %s\n' '----i---------e----' "$target"
else
printf '%s %s\n' '------------------e----' "$target"
fi
exit 0
LSATTRSHIM
chmod +x "${FAKE_BIN}/lsattr"
for tool in chattr logger sudo systemd-resolved; do
cat >"${FAKE_BIN}/${tool}" <<SIMPLESHIM
#!/usr/bin/env bash
printf '%s\n' "${tool} \$*" >>"\${SERVICES_TEST_DEV}/calls"
exit 0
SIMPLESHIM
chmod +x "${FAKE_BIN}/${tool}"
done
# shellcheck source=services_path.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/services_path.sh"
present_command() { # <name>...
local name
for name in "$@"; do
printf '#!/usr/bin/env bash\nexit 0\n' >"${FAKE_BIN}/${name}"
chmod +x "${FAKE_BIN}/${name}"
done
}
absent_command() { # <name>...
local name
for name in "$@"; do
rm -f "${FAKE_BIN}/${name}"
done
}
export SERVICES_TEST_DEV="${DEV}"
export PATH="${FAKE_BIN}:${REAL_BIN}"
# --- fixture filesystem -----------------------------------------------------
# Every absolute path the check libs probe is prefixed with $SYSROOT, which they
# read from SERVICES_ROOT. Pointing it at the tmpdir is what makes the
# file-existence branches reachable: the checks run their real logic, against a
# tree this process created. SERVICES_ROOT is deliberately un-defaulted in the
# libs, so a test file that forgets this line dies instead of touching /etc.
readonly SYSROOT_DIR="${TEST_TMPDIR}/sysroot"
export SERVICES_ROOT="${SYSROOT_DIR}"
mkdir -p \
"${SYSROOT_DIR}/usr/bin" \
"${SYSROOT_DIR}/usr/local/bin" \
"${SYSROOT_DIR}/etc/systemd/system" \
"${SYSROOT_DIR}/etc/pacman.d/hooks" \
"${SYSROOT_DIR}/var/lib"
# Create a file under the fixture root, making its parent as needed.
# `sysfile etc/hosts 200` writes 200 filler lines, which is how the
# StevenBlack-list line-count check is driven.
sysfile() { # <relative-path> [line-count]
local target="${SYSROOT_DIR}/$1"
mkdir -p "$(dirname "$target")"
if [[ -n "${2:-}" ]]; then
seq 1 "$2" >"$target"
else
: >"$target"
fi
}
# Remove a file or directory from the fixture root.
sysrm() { # <relative-path>...
local rel
for rel in "$@"; do
rm -rf "${SYSROOT_DIR:?}/${rel}"
done
}
# --- globals the libs read --------------------------------------------------
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[0;33m'
BLUE=$'\033[0;34m'
CYAN=$'\033[0;36m'
NC=$'\033[0m'
DRY_RUN=0
STATUS_ONLY=0
ISSUES_FOUND=0
FIXES_APPLIED=0
declare -A SERVICE_STATUS=()
declare -a MISSING_SCRIPTS=()
# Installer paths point into the tmpdir so a test controls whether the repair
# path finds its script (present) or reports it missing (absent).
#
# Exported because the check libs that read them are sourced by the individual
# test files rather than by this harness, so standalone shellcheck sees only
# the write here and reports SC2034. They genuinely cross a process boundary
# too (the require_root probe runs as a child), so export is the accurate
# declaration rather than a way to quiet the warning.
export PACMAN_WRAPPER_INSTALL="${TEST_TMPDIR}/install_pacman_wrapper.sh"
export MAKEPKG_WRAPPER_INSTALL="${TEST_TMPDIR}/install_makepkg_wrapper.sh"
export PACMAN_WRAPPER_MANIFEST="${TEST_TMPDIR}/pacman-source.sha256"
export MAKEPKG_WRAPPER_MANIFEST="${TEST_TMPDIR}/makepkg-source.sha256"
export MIDNIGHT_SHUTDOWN_SCRIPT="${TEST_TMPDIR}/setup_midnight_shutdown.sh"
export STARTUP_MONITOR_SCRIPT="${TEST_TMPDIR}/setup_pc_startup_monitor.sh"
export PERIODIC_SYSTEM_SCRIPT="${TEST_TMPDIR}/setup_periodic_system.sh"
export HOSTS_INSTALL_SCRIPT="${TEST_TMPDIR}/hosts_install.sh"
export GUARD_LIB_MIGRATE_SCRIPT="${TEST_TMPDIR}/migrate_hosts_guard.sh"
export COMPULSIVE_BLOCK_SCRIPT="${TEST_TMPDIR}/block_compulsive_opening.sh"
export LEECHBLOCK_SCRIPT="${TEST_TMPDIR}/install_leechblock.sh"
export REMOVE_GUEST_MODE_SCRIPT="${TEST_TMPDIR}/remove_guest_mode.sh"
export VBOX_HOSTS_SCRIPT="${TEST_TMPDIR}/enforce_vbox_hosts.sh"
export WORKOUT_LOCKER_INSTALL_SCRIPT="${TEST_TMPDIR}/install_systemd.sh"
export WORKOUT_LOCKER_SCRIPT="${TEST_TMPDIR}/screen_lock.py"
# Create an installer stub that records being run, so "did the repair path fire"
# is observable without any real installation happening.
make_installer() { # <path>
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "ran %s $*" >>"%s/calls"\nexit 0\n' \
"$(basename "$1")" "${DEV}" >"$1"
chmod +x "$1"
}
# shellcheck source=../services_common.sh
. "${LIB_DIR}/services_common.sh"
# Stage a fully healthy blocking stack: a long hosts file, the guard-lib trio
# active, both pacman hooks, and a resolved.conf that reads /etc/hosts.
stage_hosts_ok() {
sysfile etc/hosts 200
sysfile etc/hosts.stevenblack 200
printf '%s\n' "${SERVICES_ROOT}/etc/hosts" >"${DEV}/immutable"
printf 'hosts: files resolve dns\n' >"${SERVICES_ROOT}/etc/nsswitch.conf"
printf '[Resolve]\nReadEtcHosts=yes\n' >"${SERVICES_ROOT}/etc/systemd/resolved.conf"
printf '%s\n' hosts nsswitch resolved >"${DEV}/guard_healthy"
sysfile etc/pacman.d/hooks/10-guard-lib-unlock-all.hook
sysfile etc/pacman.d/hooks/90-guard-lib-relock-all.hook
}
# reset_state — return every mutable global and every $DEV fact to "nothing has
# happened yet", so each test group starts from a known machine.
reset_state() {
rm -f "${DEV:?}"/* 2>/dev/null || true
rm -rf "${SYSROOT_DIR:?}"
mkdir -p \
"${SYSROOT_DIR}/usr/bin" \
"${SYSROOT_DIR}/usr/local/bin" \
"${SYSROOT_DIR}/etc/systemd/system" \
"${SYSROOT_DIR}/etc/pacman.d/hooks" \
"${SYSROOT_DIR}/var/lib"
: >"${DEV}/calls"
DRY_RUN=0
STATUS_ONLY=0
ISSUES_FOUND=0
FIXES_APPLIED=0
SERVICE_STATUS=()
MISSING_SCRIPTS=()
# Staged fake executables are part of the machine a test describes, so they
# reset too. Without this a `present_command discord` leaks into every later
# group, which makes a forgetful test pass for the wrong reason.
rm -f "${FAKE_BIN:?}"/beeper "${FAKE_BIN}"/signal-desktop "${FAKE_BIN}"/discord \
"${FAKE_BIN}"/thorium-browser "${FAKE_BIN}"/chromium \
"${FAKE_BIN}"/google-chrome "${FAKE_BIN}"/brave-browser \
"${FAKE_BIN}"/VBoxManage
}
reset_state