fix(lib,uninstall): warn-and-no-op state ops, best-effort teardown, remnants fallback (L1-L4,L8,L10-L13)

- state.sh mutating ops genuinely warn-and-no-op on OSError; reads
  quiet-empty; save refuses to clobber unread state (L1)
- uninstall lib: guarded state_list pipeline (L2), _u_try wrapper so
  rm/mkdir failures never abort mid-teardown (L3), telemetry respects
  dry-run + --max-time 5 (L8)
- root uninstall.sh: inline best-effort fallback teardown when the lib
  is missing — remnants-only boxes can now be uninstalled (L4)
- pkg_refresh matches its never-propagate docstring (L10); ufw detect
  capture-then-case (L11); init/firewall zero-arg safety, init cache
  actually used, init_reload contract documented (L13)
- 18 new proving assertions in test_lib.sh (31 passed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Fable 5 committed 2026-07-02 17:59:55 -04:00
1 parent 8a2d2ffd83
commit ccfd9ed227
7 files changed
+488 -101

No files matched your search

+20 -5
View File
@@ -47,8 +47,16 @@ firewall_detect() {
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
printf 'firewalld\n'; return 0
fi
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qiE '^Status:[[:space:]]*active'; then
printf 'ufw\n'; return 0
if command -v ufw >/dev/null 2>&1; then
# Capture-then-case rather than `ufw status | grep -q`: grep -q exits
# at the first match, ufw can then die of SIGPIPE, and under a caller's
# pipefail the pipeline goes non-zero — misdetecting an active ufw.
local ufw_out
ufw_out="$(ufw status 2>/dev/null || true)"
case "$ufw_out" in
*[Ss]tatus:*[Ii]nactive*) : ;; # "inactive" contains "active" — rule it out first
*[Ss]tatus:*[Aa]ctive*) printf 'ufw\n'; return 0 ;;
esac
fi
if command -v nft >/dev/null 2>&1 && nft list ruleset >/dev/null 2>&1; then
printf 'nftables\n'; return 0
@@ -64,7 +72,11 @@ _fw_port() { printf '%s' "${1%%/*}"; }
_fw_proto() { case "$1" in */*) printf '%s' "${1##*/}" ;; *) printf 'tcp' ;; esac; }
firewall_open() {
local backend="$1"; shift
# No backend argument (a zero-arg call under a `set -u` caller) is a
# no-op, not an unbound-variable abort.
local backend="${1:-}"
[ -n "$backend" ] || return 0
shift
local spec port proto
case "$backend" in
firewalld)
@@ -102,7 +114,10 @@ firewall_open() {
}
firewall_close() {
local backend="$1"; shift
# Same zero-arg guard as firewall_open.
local backend="${1:-}"
[ -n "$backend" ] || return 0
shift
local spec port proto
case "$backend" in
firewalld)
@@ -134,6 +149,6 @@ firewall_close() {
# Warn-and-document fallback text for when no firewall could be driven.
firewall_manual_hint() {
local ports="$1"
local ports="${1:-}"
printf 'Open these ports manually so the panel is reachable: %s\n' "$ports"
}
+42 -36
View File
@@ -18,7 +18,11 @@
# init_stop <service> → stop a service
# init_enable <service> → enable at boot
# init_disable <service> → disable at boot
# init_reload [service] → daemon-reload (no arg) / reload a service
# init_reload [service] → with a service: reload it, propagating the
# tool's exit exactly like init_start/stop;
# with no arg: systemd daemon-reload (a
# successful no-op on every other init system,
# where the concept does not exist)
# init_is_active <service> → return 0 if running, 1 otherwise (no sudo)
#
# Honors:
@@ -30,9 +34,9 @@
# enable/disable (which is symlink-based) never aborts even if the symlink op
# fails.
# Cache of the detected init system. Populated lazily, but every public function
# still calls init_detect so that INIT_OVERRIDE/INIT_DRY_RUN flipped per-call in
# tests always take effect — init_detect honours the override before this cache.
# Cache of the detected init system: probing /proc and PATH once per process is
# enough. init_detect honours INIT_OVERRIDE *before* this cache, so an override
# flipped per-call in tests always takes effect.
_INIT_DETECTED=""
# Prepend sudo when we are not already root and sudo exists. Used only for the
@@ -71,6 +75,10 @@ init_detect() {
printf '%s\n' "$INIT_OVERRIDE"
return 0
fi
if [ -n "$_INIT_DETECTED" ]; then
printf '%s\n' "$_INIT_DETECTED"
return 0
fi
local comm=""
if [ -d /run/systemd/system ]; then
_INIT_DETECTED="systemd"; printf 'systemd\n'; return 0
@@ -97,14 +105,21 @@ init_detect() {
# Print "No init system detected" and fail. Centralised so every mutating
# function reports the same actionable message.
_init_no_init() {
local action="$1" svc="$2"
local action="${1:-}" svc="${2:-}"
printf ' [init] No init system detected — cannot %s %s; start it manually\n' \
"$action" "$svc" >&2
return 1
}
# A zero-arg call under a `set -u` caller must warn-and-fail, never die on an
# unbound $1 expansion. Callers get the same return 1 as any other failure.
_init_no_svc() {
printf ' [init] %s requires a service name\n' "${1:-}" >&2
}
init_start() {
local svc="$1" init
local svc="${1:-}" init
[ -n "$svc" ] || { _init_no_svc init_start; return 1; }
init="$(init_detect)"
case "$init" in
systemd) _init_run "$(_init_sudo)" systemctl start "$svc" ;;
@@ -116,7 +131,8 @@ init_start() {
}
init_stop() {
local svc="$1" init
local svc="${1:-}" init
[ -n "$svc" ] || { _init_no_svc init_stop; return 1; }
init="$(init_detect)"
case "$init" in
systemd) _init_run "$(_init_sudo)" systemctl stop "$svc" ;;
@@ -128,7 +144,8 @@ init_stop() {
}
init_enable() {
local svc="$1" init
local svc="${1:-}" init
[ -n "$svc" ] || { _init_no_svc init_enable; return 1; }
init="$(init_detect)"
case "$init" in
systemd)
@@ -158,7 +175,8 @@ init_enable() {
}
init_disable() {
local svc="$1" init
local svc="${1:-}" init
[ -n "$svc" ] || { _init_no_svc init_disable; return 1; }
init="$(init_detect)"
case "$init" in
systemd)
@@ -189,41 +207,29 @@ init_disable() {
init_reload() {
local svc="${1:-}" init
init="$(init_detect)"
case "$init" in
systemd)
if [ -n "$svc" ]; then
_init_run "$(_init_sudo)" systemctl reload "$svc"
else
# Bare `init_reload` means "systemd daemon-reload"; the concept does not
# exist on other init systems, where it is a successful no-op.
if [ -z "$svc" ]; then
[ "$init" = "systemd" ] || return 0
_init_run "$(_init_sudo)" systemctl daemon-reload
return
fi
;;
openrc)
[ -n "$svc" ] && _init_run "$(_init_sudo)" rc-service "$svc" reload
return 0
;;
runit)
[ -n "$svc" ] && _init_run "$(_init_sudo)" sv reload "$svc"
return 0
;;
sysvinit)
[ -n "$svc" ] && _init_run "$(_init_sudo)" service "$svc" reload
return 0
;;
*)
# daemon-reload has no meaning without an init system; a named reload
# is the only mutating intent worth warning about.
if [ -n "$svc" ]; then
_init_no_init "reload" "$svc"
fi
return 0
;;
# A named reload behaves exactly like init_start/stop: it propagates the
# tool's exit, and warns-and-fails when no init system can be driven.
case "$init" in
systemd) _init_run "$(_init_sudo)" systemctl reload "$svc" ;;
openrc) _init_run "$(_init_sudo)" rc-service "$svc" reload ;;
runit) _init_run "$(_init_sudo)" sv reload "$svc" ;;
sysvinit) _init_run "$(_init_sudo)" service "$svc" reload ;;
*) _init_no_init "reload" "$svc" ;;
esac
}
# Report whether a service is currently running. Read-only: no sudo, and the
# tool's own output is discarded so callers can rely purely on the exit status.
init_is_active() {
local svc="$1" init
local svc="${1:-}" init
[ -n "$svc" ] || return 1 # no name → not running (read-only, stay quiet)
init="$(init_detect)"
case "$init" in
systemd) systemctl is-active --quiet "$svc" ;;
+10 -8
View File
@@ -80,19 +80,21 @@ pkg_detect() {
# Refresh the package index for the detected manager. Best-effort: index
# refreshes routinely fail on a flaky mirror and that should not abort an
# install, so we never propagate the manager's exit here.
# install, so we never propagate the manager's exit here — every arm is
# guarded and the function always returns 0.
pkg_refresh() {
local mgr
mgr="$(pkg_detect)"
case "$mgr" in
apt) _pkg_run apt-get update ;;
dnf) _pkg_run dnf makecache --refresh ;;
yum) _pkg_run yum makecache ;;
zypper) _pkg_run zypper --non-interactive refresh ;;
pacman) _pkg_run pacman -Sy --noconfirm ;;
apk) _pkg_run apk update ;;
*) return 0 ;;
apt) _pkg_run apt-get update || true ;;
dnf) _pkg_run dnf makecache --refresh || true ;;
yum) _pkg_run yum makecache || true ;;
zypper) _pkg_run zypper --non-interactive refresh || true ;;
pacman) _pkg_run pacman -Sy --noconfirm || true ;;
apk) _pkg_run apk update || true ;;
*) : ;;
esac
return 0
}
# Install one or more packages non-interactively for the detected manager.
+51 -14
View File
@@ -10,7 +10,12 @@
#
# Backed by python3, which is a hard ServerKit dependency (the whole backend is
# Python), so it is present on any installed box. If python3 is somehow missing
# the helpers degrade to warn-and-no-op rather than aborting the caller.
# the helpers degrade to warn-and-no-op rather than aborting the caller. The
# same contract holds when python3 IS present but the state file can't be
# used (read-only fs, ENOSPC, unwritable parent, path-is-a-directory):
# mutating ops warn to stderr and exit 0, read ops stay quiet and print
# nothing — recording state is best-effort bookkeeping and must never abort
# a `set -e` caller.
#
# API:
# state_set <key> <value> set a scalar
@@ -35,6 +40,17 @@ action = sys.argv[1] if len(sys.argv) > 1 else ""
key = sys.argv[2] if len(sys.argv) > 2 else ""
value = sys.argv[3] if len(sys.argv) > 3 else ""
def warn(msg):
try:
sys.stderr.write(" [state] %s\n" % msg)
except OSError:
pass
# Reads are quiet-empty on any I/O problem: a missing, unreadable or
# corrupt state file means "empty state" — an observation never aborts.
# We remember when an EXISTING file could not be read so a later save()
# can refuse to clobber state it never saw.
load_failed = False
try:
with open(path) as fh:
data = json.load(fh)
@@ -42,23 +58,36 @@ try:
data = {}
except (FileNotFoundError, ValueError):
data = {}
except OSError:
data = {}
load_failed = True
def save():
# Mutations are warn-and-no-op on failure (read-only fs, ENOSPC,
# unwritable parent, path-is-a-directory): the header promises this
# degrades rather than aborting a `set -e` caller.
if load_failed:
warn("cannot read %s — not recording %s" % (path, key))
return
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as fh:
json.dump(data, fh, indent=2, sort_keys=True)
fh.write("\n")
os.replace(tmp, path)
except OSError as exc:
warn("cannot write %s (%s) — not recording %s" % (path, exc, key))
if action == "set":
try:
if action == "set":
data[key] = value
save()
elif action == "get":
elif action == "get":
v = data.get(key, "")
if v is not None:
sys.stdout.write(str(v))
elif action == "append":
elif action == "append":
arr = data.get(key)
if not isinstance(arr, list):
arr = []
@@ -66,40 +95,48 @@ elif action == "append":
arr.append(value)
data[key] = arr
save()
elif action == "list":
elif action == "list":
arr = data.get(key)
if isinstance(arr, list):
for item in arr:
sys.stdout.write(str(item) + "\n")
elif action == "unset":
elif action == "unset":
data.pop(key, None)
save()
else:
else:
sys.exit(2)
except BrokenPipeError:
# A downstream consumer closed the pipe early (state_list | head ...).
# Not an error for an observation; silence the interpreter's exit-time
# flush and leave with 0 so a pipefail caller is unaffected.
try:
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
except OSError:
pass
PY
}
state_set() {
_state_have_py || { printf ' [state] python3 unavailable — not recording %s\n' "$1" >&2; return 0; }
_state_py set "$1" "$2"
_state_have_py || { printf ' [state] python3 unavailable — not recording %s\n' "${1:-}" >&2; return 0; }
_state_py set "${1:-}" "${2:-}"
}
state_get() {
_state_have_py || return 0
_state_py get "$1"
_state_py get "${1:-}"
}
state_append() {
_state_have_py || { printf ' [state] python3 unavailable — not recording %s\n' "$1" >&2; return 0; }
_state_py append "$1" "$2"
_state_have_py || { printf ' [state] python3 unavailable — not recording %s\n' "${1:-}" >&2; return 0; }
_state_py append "${1:-}" "${2:-}"
}
state_list() {
_state_have_py || return 0
_state_py list "$1"
_state_py list "${1:-}"
}
state_unset() {
_state_have_py || return 0
_state_py unset "$1"
_state_py unset "${1:-}"
}
+39 -21
View File
@@ -49,6 +49,18 @@ _u_run() {
"$@"
}
# Best-effort teardown step (rule R7): run it and, on failure, warn and keep
# going. By the time these steps run the services are already stopped, so an
# uninstall must never die mid-teardown and strand the box half-removed over a
# single EBUSY (live bind-mount), EROFS or ENOSPC rm/mkdir.
_u_try() {
if _u_run "$@"; then
return 0
fi
_u_warn "Step failed (continuing): $*"
return 0
}
# Remove the firewall rules we (and only we) added, reading the record left in
# install-state.json. Must run BEFORE the config dir is deleted.
_u_firewall_cleanup() {
@@ -64,7 +76,10 @@ _u_firewall_cleanup() {
local backend ports
backend="$(state_get firewall_backend 2>/dev/null || true)"
[ -n "$backend" ] || return 0
ports="$(state_list firewall_ports 2>/dev/null | tr '\n' ' ')"
# `|| true` mirrors the state_get line above: under the entry points'
# pipefail a failing state_list would otherwise abort the WHOLE uninstall
# before anything was removed.
ports="$(state_list firewall_ports 2>/dev/null | tr '\n' ' ' || true)"
[ -n "$ports" ] || return 0
_u_info "Removing firewall rules we added ($backend): $ports"
@@ -119,44 +134,44 @@ serverkit_uninstall_core() {
# ---- systemd unit ------------------------------------------------------
_u_info "Removing systemd service..."
_u_run rm -f /etc/systemd/system/serverkit.service
_u_try rm -f /etc/systemd/system/serverkit.service
_u_run systemctl daemon-reload 2>/dev/null || true
_u_ok "Systemd service removed"
# ---- nginx ServerKit-owned configs -------------------------------------
_u_info "Removing nginx configuration..."
_u_run rm -f /etc/nginx/sites-enabled/serverkit.conf
_u_run rm -f /etc/nginx/sites-available/serverkit.conf
_u_run rm -f /etc/nginx/sites-available/serverkit-insecure.conf
_u_run rm -f /etc/nginx/sites-available/example.conf.template
_u_run rm -f /etc/nginx/conf.d/serverkit-tls.conf
_u_run rm -rf /etc/nginx/serverkit-conf.d
_u_try rm -f /etc/nginx/sites-enabled/serverkit.conf
_u_try rm -f /etc/nginx/sites-available/serverkit.conf
_u_try rm -f /etc/nginx/sites-available/serverkit-insecure.conf
_u_try rm -f /etc/nginx/sites-available/example.conf.template
_u_try rm -f /etc/nginx/conf.d/serverkit-tls.conf
_u_try rm -rf /etc/nginx/serverkit-conf.d
if systemctl is-active --quiet nginx 2>/dev/null; then
_u_run systemctl reload nginx 2>/dev/null || true
fi
_u_ok "Nginx config removed"
# ---- apt lock-wait drop-in (only the installer creates this) -----------
_u_run rm -f /etc/apt/apt.conf.d/99-serverkit-lock-wait.conf
_u_try rm -f /etc/apt/apt.conf.d/99-serverkit-lock-wait.conf
# ---- CLI symlink -------------------------------------------------------
_u_run rm -f /usr/local/bin/serverkit
_u_try rm -f /usr/local/bin/serverkit
# ---- Code: install dir + blue/green slots + backup snapshot ------------
_u_info "Removing installation files..."
_u_run rm -rf "$install_dir"
_u_run rm -rf "${install_dir}-a"
_u_run rm -rf "${install_dir}-b"
_u_run rm -rf "${install_dir}.backup"
_u_try rm -rf "$install_dir"
_u_try rm -rf "${install_dir}-a"
_u_try rm -rf "${install_dir}-b"
_u_try rm -rf "${install_dir}.backup"
_u_ok "Installation directory removed"
# ---- Logs (always transient) -------------------------------------------
_u_run rm -rf "$log_dir"
_u_try rm -rf "$log_dir"
# ---- Data tiers --------------------------------------------------------
if [ "$purge" = "1" ]; then
_u_info "Purging data (--purge)..."
_u_run rm -rf "$data_dir" "$apps_dir" "$backup_dir" "$config_dir"
_u_try rm -rf "$data_dir" "$apps_dir" "$backup_dir" "$config_dir"
_u_ok "Data directories removed"
else
# Config dir holds install-state.json + ssl-mode + templates. Removed by
@@ -164,13 +179,16 @@ serverkit_uninstall_core() {
if [ "$keep_data" = "1" ]; then
_u_info "Preserving data (--keep-data): $data_dir, $config_dir, $backup_dir"
else
_u_run rm -rf "$config_dir"
_u_try rm -rf "$config_dir"
_u_info "Preserved user data: $data_dir, $apps_dir, $backup_dir"
fi
fi
# ---- Telemetry (best-effort) -------------------------------------------
curl -s "https://serverkit.ai/track/uninstall" >/dev/null 2>&1 || true
# ---- Telemetry (best-effort; bounded so an air-gapped box never hangs,
# ---- and a --dry-run never phones home) ---------------------------------
if [ "${SERVERKIT_UNINSTALL_DRY_RUN:-0}" != "1" ]; then
curl -s --max-time 5 "https://serverkit.ai/track/uninstall" >/dev/null 2>&1 || true
fi
}
# Snapshot the live SQLite DB into the (preserved) backup dir so a default
@@ -184,7 +202,7 @@ _u_snapshot_db() {
# Hybrid: DB sits in the install tree.
db_src="$install_dir/backend/instance/serverkit.db"
if [ -f "$db_src" ]; then
_u_run mkdir -p "$backup_dir"
_u_try mkdir -p "$backup_dir"
if _u_run cp "$db_src" "$dest" 2>/dev/null; then
_u_ok "Database snapshot saved to $dest"
return 0
@@ -194,7 +212,7 @@ _u_snapshot_db() {
# All-Docker: DB lives in the backend container's volume.
if command -v docker >/dev/null 2>&1 && \
docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx 'serverkit-backend'; then
_u_run mkdir -p "$backup_dir"
_u_try mkdir -p "$backup_dir"
if _u_run docker cp serverkit-backend:/app/instance/serverkit.db "$dest" 2>/dev/null; then
_u_ok "Database snapshot saved to $dest"
return 0
+264 -4
View File
@@ -4,21 +4,33 @@
# scripts/lib/pkg.sh — package-manager abstraction
# scripts/lib/init.sh — init-system / service control
# scripts/lib/env.sh — container / WSL / systemd detection
# scripts/lib/state.sh — install-state tracker (warn-and-no-op contract)
# scripts/lib/firewall.sh — firewall abstraction (detection under pipefail)
# scripts/lib/uninstall.sh — canonical teardown (never dies mid-teardown)
# plus the root uninstall.sh entry point's remnants-only fallback path.
#
# All three are pure, override-friendly, and dry-run aware, so they test
# deterministically on any host (including this Windows/Git-Bash dev box)
# without touching a real package manager, init system, or /proc.
# Everything is pure, override-friendly, and dry-run aware — or exercised
# against throwaway fixtures and PATH stubs — so it tests deterministically on
# any host (including this Windows/Git-Bash dev box) without touching a real
# package manager, init system, firewall, or /etc.
#
# Each unit-under-test runs in a subshell that re-enables `set -Eeuo pipefail`,
# so an unguarded command silently aborting under set -e is caught here as a
# failed assertion rather than on someone's server.
#
# Run: bash scripts/test/test_lib.sh
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
PASS=0
FAIL=0
SKIP=0
ok() { PASS=$((PASS + 1)); printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { FAIL=$((FAIL + 1)); printf ' \033[31m✘\033[0m %s\n' "$1"; }
skip() { SKIP=$((SKIP + 1)); printf ' \033[33m∼\033[0m %s (skipped)\n' "$1"; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
@@ -147,6 +159,254 @@ else
bad "has_systemd force-override broken"
fi
# ==========================================================================
# init.sh — L13: zero-arg safety under set -u, the detection cache, and the
# init_reload contract.
# ==========================================================================
errf="$WORK/init-noargs.err"
if ( set -Eeuo pipefail; init_start ) 2>"$errf"; then
bad "init_start with no args must return non-zero"
elif grep -q 'unbound variable' "$errf"; then
bad "init_start with no args died on an unbound \$1 instead of a clean warn"
elif grep -q 'requires a service name' "$errf"; then
ok "init_start with no args warns and returns 1 (set -u safe)"
else
bad "init_start with no args returned 1 but without the missing-name warning"
fi
if ( set -Eeuo pipefail; init_is_active ) 2>"$errf"; then
bad "init_is_active with no args must return non-zero"
elif grep -q 'unbound variable' "$errf"; then
bad "init_is_active with no args died on an unbound \$1 under set -u"
else
ok "init_is_active with no args quietly returns 1 (set -u safe)"
fi
# The lazily-populated cache must be read back — and the override must still
# beat it (that ordering is what keeps per-call INIT_OVERRIDE tests working).
got="$( set -Eeuo pipefail; INIT_OVERRIDE="" _INIT_DETECTED=openrc init_detect )"
got2="$( set -Eeuo pipefail; INIT_OVERRIDE=runit _INIT_DETECTED=openrc init_detect )"
if [ "$got" = "openrc" ] && [ "$got2" = "runit" ]; then
ok "init_detect serves the _INIT_DETECTED cache, but INIT_OVERRIDE still wins"
else
bad "init_detect cache/override order wrong (cache→[$got], override→[$got2])"
fi
# init_reload: a NAMED reload behaves like init_start (emits the tool command,
# warns-and-fails under 'none'); a BARE reload is a systemd-only no-op.
out="$( set -Eeuo pipefail; INIT_OVERRIDE=openrc INIT_DRY_RUN=1 init_reload serverkit 2>&1 )"
reload_ok=1
printf '%s' "$out" | grep -qF "rc-service serverkit reload" || { bad "init_reload openrc dry-run: [$out]"; reload_ok=0; }
( set -Eeuo pipefail; INIT_OVERRIDE=openrc init_reload ) >/dev/null 2>&1 || { bad "bare init_reload must be a no-op 0 on non-systemd inits"; reload_ok=0; }
( set -Eeuo pipefail; INIT_OVERRIDE=none init_reload serverkit ) >/dev/null 2>&1 && { bad "named init_reload under 'none' must return non-zero like its siblings"; reload_ok=0; }
[ "$reload_ok" = "1" ] && ok "init_reload: named reload matches the siblings' contract; bare reload is systemd-only"
# ==========================================================================
# pkg.sh — L10: pkg_refresh promises "never propagate the manager's exit".
# ==========================================================================
t="$WORK/pkgrefresh"; mkdir -p "$t/bin"
printf '#!/bin/sh\nexit 7\n' > "$t/bin/apt-get"; chmod +x "$t/bin/apt-get"
# sudo shim: on a non-root Linux host _pkg_sudo would prepend sudo; keep the
# stub chain deterministic everywhere.
printf '#!/bin/sh\nexec "$@"\n' > "$t/bin/sudo"; chmod +x "$t/bin/sudo"
if ( set -Eeuo pipefail; PATH="$t/bin:$PATH" PKG_MGR_OVERRIDE=apt PKG_DRY_RUN=0 pkg_refresh ); then
ok "pkg_refresh returns 0 when the manager's refresh fails (best-effort doc contract)"
else
bad "pkg_refresh propagated a failing package manager's exit (L10 regression)"
fi
# ==========================================================================
# firewall.sh — L11: ufw detection is capture-then-case (no `| grep -q`
# SIGPIPE race under a pipefail caller), and "inactive" is never misread as
# active. L13: zero-arg calls are safe no-ops under set -u.
# ==========================================================================
t="$WORK/fw"; mkdir -p "$t/on" "$t/off"
# The active stub prints a rule table far bigger than a 64K pipe buffer: with
# the old `ufw status | grep -q` shape, grep exits at the first line and the
# still-writing ufw dies of SIGPIPE — flipping the pipeline non-zero under
# pipefail and misdetecting the box. Capture-then-case must shrug this off.
cat > "$t/on/ufw" <<'UFW_EOF'
#!/bin/sh
echo "Status: active"
i=0
while [ "$i" -lt 2000 ]; do
echo "22/tcp ALLOW Anywhere # filler $i"
i=$((i+1))
done
UFW_EOF
chmod +x "$t/on/ufw"
printf '#!/bin/sh\necho "Status: inactive"\n' > "$t/off/ufw"; chmod +x "$t/off/ufw"
got="$( set -Eeuo pipefail; source "$LIB_DIR/firewall.sh"; PATH="$t/on" FIREWALL_BACKEND="" firewall_detect )"
got2="$( set -Eeuo pipefail; source "$LIB_DIR/firewall.sh"; PATH="$t/off" FIREWALL_BACKEND="" firewall_detect )"
if [ "$got" = "ufw" ] && [ "$got2" = "none" ]; then
ok "firewall_detect sees an active ufw under pipefail and never misreads 'inactive'"
else
bad "firewall_detect wrong: active→[$got] (want ufw), inactive→[$got2] (want none)"
fi
errf="$WORK/fw-noargs.err"
if ( set -Eeuo pipefail; source "$LIB_DIR/firewall.sh"
firewall_open && firewall_close && firewall_manual_hint >/dev/null ) 2>"$errf"; then
if grep -q 'unbound variable' "$errf"; then
bad "zero-arg firewall helpers leaked an unbound-variable error"
else
ok "firewall_open/close/manual_hint with zero args are safe no-ops under set -u"
fi
else
bad "zero-arg firewall helpers aborted under set -u: $(tr -d '\r' < "$errf")"
fi
# ==========================================================================
# state.sh — L1: the header promises warn-and-no-op, so a PRESENT python3
# with an unusable state file (path is a directory, parent is a file,
# unwritable dir) must warn and exit 0 for mutations, and stay quiet-empty
# for reads — never abort a `set -Eeuo pipefail` caller.
# ==========================================================================
if command -v python3 >/dev/null 2>&1; then
sdir="$WORK/state-as-dir"; mkdir -p "$sdir"
errf="$WORK/state-l1.err"
if ( set -Eeuo pipefail; source "$LIB_DIR/state.sh"
SERVERKIT_STATE_FILE="$sdir" state_set firewall_backend ufw ) 2>"$errf"; then
if grep -q '\[state\]' "$errf"; then
ok "state_set warns and exits 0 when the state path is a directory"
else
bad "state_set exited 0 on a directory state path but never warned"
fi
else
bad "state_set ABORTED when the state path is a directory (L1 regression)"
fi
if out="$( set -Eeuo pipefail; source "$LIB_DIR/state.sh"
SERVERKIT_STATE_FILE="$sdir" state_get firewall_backend 2>/dev/null )" \
&& [ -z "$out" ]; then
ok "state_get is quiet-empty (exit 0) on an unreadable state path"
else
bad "state_get aborted or printed [$out] on an unreadable state path"
fi
if ( set -Eeuo pipefail; source "$LIB_DIR/state.sh"
ports="$(SERVERKIT_STATE_FILE="$sdir" state_list firewall_ports | tr '\n' ' ')"
[ -z "${ports// /}" ] ); then
ok "state_list | tr survives set -Eeuo pipefail on an unreadable state path"
else
bad "state_list | tr aborted under pipefail (the L2-shaped uninstall killer)"
fi
: > "$WORK/state-blocker"
if ( set -Eeuo pipefail; source "$LIB_DIR/state.sh"
SERVERKIT_STATE_FILE="$WORK/state-blocker/state.json" state_append firewall_ports 80/tcp ) 2>/dev/null; then
ok "state_append warns and exits 0 when the state parent is a regular file"
else
bad "state_append ABORTED when the state parent is a regular file (L1 regression)"
fi
# Unwritable parent dir. As root (distro-container CI) or on Windows the
# write may legitimately succeed — the contract under test is only
# "never non-zero", which must hold either way.
ro="$WORK/state-ro"; mkdir -p "$ro"; chmod 555 "$ro" 2>/dev/null || true
if ( set -Eeuo pipefail; source "$LIB_DIR/state.sh"
SERVERKIT_STATE_FILE="$ro/state.json" state_set k v ) 2>/dev/null; then
ok "state_set exits 0 with an unwritable state directory"
else
bad "state_set ABORTED on an unwritable state directory (L1 regression)"
fi
chmod 755 "$ro" 2>/dev/null || true
else
skip "state.sh warn-and-no-op contract — python3 unavailable here"
fi
# ==========================================================================
# lib/uninstall.sh — L2: a failing `state_list | tr` must not abort the whole
# uninstall; L3: failed rm/mkdir teardown steps warn and continue (_u_try);
# L8: telemetry curl is bounded with --max-time and skipped under --dry-run.
# ==========================================================================
t="$WORK/ucore"; mkdir -p "$t/bin" "$t/inst/scripts/lib"
# Hostile state fixture: state_list emits one port then FAILS mid-pipe —
# exactly the shape that used to kill the uninstall before it removed anything.
cat > "$t/inst/scripts/lib/state.sh" <<'HOSTILE_EOF'
state_get() { printf 'iptables'; }
state_list() { printf '80/tcp\n'; return 1; }
HOSTILE_EOF
printf 'firewall_close() { return 0; }\n' > "$t/inst/scripts/lib/firewall.sh"
# rm always fails (EBUSY/EROFS stand-in); docker/systemctl fail hermetically;
# curl logs its argv so the telemetry contract is observable.
printf '#!/bin/sh\nexit 1\n' > "$t/bin/rm"; chmod +x "$t/bin/rm"
printf '#!/bin/sh\nexit 1\n' > "$t/bin/docker"; chmod +x "$t/bin/docker"
printf '#!/bin/sh\nexit 1\n' > "$t/bin/systemctl"; chmod +x "$t/bin/systemctl"
CURL_LOG="$t/curl.log"; : > "$CURL_LOG"
cat > "$t/bin/curl" <<EOF
#!/bin/sh
printf '%s\n' "\$*" >> "$CURL_LOG"
exit 0
EOF
chmod +x "$t/bin/curl"
out="$(
set -Eeuo pipefail
source "$LIB_DIR/uninstall.sh"
export PATH="$t/bin:$PATH"
export SERVERKIT_DIR="$t/inst"
serverkit_uninstall_core 2>&1
)"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF 'Removing firewall rules we added (iptables): 80/tcp'; then
ok "uninstall survives a state_list failing mid-pipe and still gets its firewall record (L2)"
else
bad "uninstall rc=$rc or never reached the firewall step past the failing state_list: [$out]"
fi
if printf '%s' "$out" | grep -qF 'Step failed (continuing): rm' && \
printf '%s' "$out" | grep -qF 'Preserved user data'; then
ok "uninstall warns on every failed rm and continues to the last teardown step (L3/_u_try)"
else
bad "uninstall did not warn-and-continue past failing rm steps (L3 regression): [$out]"
fi
if grep -q -- '--max-time 5' "$CURL_LOG"; then
ok "telemetry curl is bounded with --max-time (no air-gapped hang) (L8)"
else
bad "telemetry curl ran unbounded; curl saw: $(tr '\n' ';' < "$CURL_LOG")"
fi
: > "$CURL_LOG"
if (
set -Eeuo pipefail
source "$LIB_DIR/uninstall.sh"
export PATH="$t/bin:$PATH"
export SERVERKIT_DIR="$t/inst" SERVERKIT_UNINSTALL_DRY_RUN=1
serverkit_uninstall_core
) >/dev/null 2>&1 && [ ! -s "$CURL_LOG" ]; then
ok "uninstall --dry-run never phones home (L8)"
else
bad "uninstall --dry-run failed or still called curl: $(tr '\n' ';' < "$CURL_LOG")"
fi
# ==========================================================================
# root uninstall.sh — L4: a remnants-only box (no scripts/lib anywhere) must
# still be uninstallable via the inline fallback teardown, honoring --dry-run.
# ==========================================================================
t="$WORK/fallback"; mkdir -p "$t/remnants" "$t/remnants-a"
printf '1.2.3\n' > "$t/remnants/VERSION"
printf 'x\n' > "$t/remnants/leftover.txt"
cp "$ROOT_DIR/uninstall.sh" "$t/uninstall.sh" # copied away from the repo so no scripts/lib is findable
out="$( cd "$t" && SERVERKIT_UNINSTALL_ALLOW_NONROOT=1 SERVERKIT_UNINSTALL_LOG="$t/un.log" \
SERVERKIT_DIR="$t/remnants" bash ./uninstall.sh --dry-run --yes 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qi 'fallback' && \
printf '%s' "$out" | grep -qF "would run: rm -rf $t/remnants" && \
[ -d "$t/remnants" ] && [ -d "$t/remnants-a" ]; then
ok "root uninstall.sh with no lib announces the fallback; --dry-run changes nothing (L4)"
else
bad "fallback dry-run rc=$rc, or it mutated the fixture: [$out]"
fi
out="$( cd "$t" && SERVERKIT_UNINSTALL_ALLOW_NONROOT=1 SERVERKIT_UNINSTALL_LOG="$t/un.log" \
SERVERKIT_DIR="$t/remnants" bash ./uninstall.sh --yes 2>&1 )"
rc=$?
if [ "$rc" -eq 0 ] && [ ! -e "$t/remnants" ] && [ ! -e "$t/remnants-a" ]; then
ok "root uninstall.sh fallback removes the remnants and exits 0 (L4)"
else
bad "fallback teardown rc=$rc; remnants left: $(ls -d "$t"/remnants* 2>/dev/null | tr '\n' ' '): [$out]"
fi
# --------------------------------------------------------------------------
printf '\n%d passed, %d failed\n\n' "$PASS" "$FAIL"
printf '\n%d passed, %d failed, %d skipped\n\n' "$PASS" "$FAIL" "$SKIP"
[ "$FAIL" -eq 0 ]
+62 -13
View File
@@ -20,7 +20,9 @@ set -Eeuo pipefail
########################################
INSTALL_DIR="${SERVERKIT_DIR:-/opt/serverkit}"
LOG_FILE="/var/log/serverkit-uninstall.log"
# SERVERKIT_UNINSTALL_LOG is a test hook (scripts/test/test_lib.sh) — real
# uninstalls always log to /var/log.
LOG_FILE="${SERVERKIT_UNINSTALL_LOG:-/var/log/serverkit-uninstall.log}"
PURGE=0
KEEP_DATA=0
@@ -58,8 +60,11 @@ while [[ $# -gt 0 ]]; do
esac
done
mkdir -p /var/log
exec > >(tee -a "$LOG_FILE") 2>&1
# Logging is best-effort: an unwritable log location must not stop an
# uninstall (or eat its output through a dead tee) — just skip the log file.
if mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null && touch "$LOG_FILE" 2>/dev/null; then
exec > >(tee -a "$LOG_FILE") 2>&1
fi
########################################
# Colors / masthead
@@ -107,7 +112,9 @@ print_header() {
# Root check
########################################
if [[ $EUID -ne 0 ]]; then
# SERVERKIT_UNINSTALL_ALLOW_NONROOT=1 is a test hook (scripts/test/test_lib.sh
# exercises the script against throwaway fixtures) — real uninstalls need root.
if [[ $EUID -ne 0 && "${SERVERKIT_UNINSTALL_ALLOW_NONROOT:-0}" != "1" ]]; then
print_error "Please run as root (sudo)"
exit 1
fi
@@ -127,10 +134,45 @@ for d in "$SELF_DIR/scripts/lib" "$INSTALL_DIR/scripts/lib"; do
fi
done
if [ -z "$UNINSTALL_LIB" ]; then
print_error "Cannot find scripts/lib/uninstall.sh — is ServerKit installed at $INSTALL_DIR?"
exit 1
# Remnants-only box: the install died early or /opt/serverkit was deleted
# by hand, so the canonical routine is gone. Refusing here used to make
# such a box impossible to clean up — fall back to a minimal teardown.
print_warning "Cannot find scripts/lib/uninstall.sh (partial or broken install at $INSTALL_DIR)."
print_warning "Falling back to a minimal cleanup of ServerKit remnants."
fi
########################################
# Minimal fallback teardown (no lib found)
########################################
# Every step is best-effort and only ever removes serverkit-named paths; data
# dirs (/var/lib/serverkit, /var/serverkit, backups) are never touched, so the
# default "preserve user data" promise holds here too.
_fb_run() {
if [ "$DRY_RUN" = "1" ]; then
echo " [uninstall] would run: $*"
return 0
fi
"$@" 2>/dev/null || true
}
fallback_teardown() {
echo "Removing ServerKit remnants (minimal fallback)..."
_fb_run systemctl stop serverkit
_fb_run systemctl disable serverkit
_fb_run rm -f /etc/systemd/system/serverkit.service
_fb_run rm -f /etc/nginx/sites-enabled/serverkit.conf
_fb_run rm -f /etc/nginx/sites-available/serverkit.conf
_fb_run rm -f /etc/nginx/sites-available/serverkit-insecure.conf
_fb_run rm -f /etc/nginx/conf.d/serverkit-tls.conf
_fb_run rm -rf /etc/nginx/serverkit-conf.d
_fb_run rm -f /etc/apt/apt.conf.d/99-serverkit-lock-wait.conf
_fb_run rm -f /usr/local/bin/serverkit
_fb_run rm -rf "$INSTALL_DIR" "${INSTALL_DIR}-a" "${INSTALL_DIR}-b" "${INSTALL_DIR}.backup"
_fb_run systemctl daemon-reload
echo "Minimal fallback cleanup finished."
}
########################################
# Confirm
########################################
@@ -157,15 +199,22 @@ fi
# Run the canonical teardown
########################################
# shellcheck source=scripts/lib/uninstall.sh
source "$UNINSTALL_LIB"
if [ -n "$UNINSTALL_LIB" ]; then
# shellcheck source=scripts/lib/uninstall.sh
source "$UNINSTALL_LIB"
export SERVERKIT_DIR="$INSTALL_DIR"
export SERVERKIT_PURGE="$PURGE"
export SERVERKIT_KEEP_DATA="$KEEP_DATA"
export SERVERKIT_UNINSTALL_DRY_RUN="$DRY_RUN"
export SERVERKIT_DIR="$INSTALL_DIR"
export SERVERKIT_PURGE="$PURGE"
export SERVERKIT_KEEP_DATA="$KEEP_DATA"
export SERVERKIT_UNINSTALL_DRY_RUN="$DRY_RUN"
serverkit_uninstall_core
serverkit_uninstall_core
else
if [ "$PURGE" = "1" ]; then
print_warning "--purge is unavailable in fallback mode — data dirs are left in place"
fi
fallback_teardown
fi
########################################
# Finish