Files
ERP/uninstall.sh
Qiufeng f9545a9d0f
Signed Release / release (push) Failing after 33s
fix: harden first-run install and clean reinstall
2026-08-04 12:59:24 +08:00

243 lines
8.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${ERP_CONFIG_FILE:-/etc/kaidi-erp/erp.env}"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-/opt/kaidi-erp}"
CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}"
STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}"
LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}"
PURGE_DATABASE=0
ASSUME_YES=0
say() { printf '[ERP Uninstall] %s\n' "$*"; }
fail() { printf '[ERP Uninstall] ERROR: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
Usage: uninstall.sh [options]
--purge-database Drop and recreate the public schema using the configured ERP account
--yes Confirm destructive removal without an interactive prompt
--config-file PATH Installer-generated erp.env file
--install-root PATH Installation root (default: /opt/kaidi-erp)
--config-root PATH Configuration root (default: /etc/kaidi-erp)
--state-root PATH State root (default: /var/lib/kaidi-erp)
--log-root PATH Log root (default: /var/log/kaidi-erp)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--purge-database) PURGE_DATABASE=1; shift ;;
--yes) ASSUME_YES=1; shift ;;
--config-file) [[ $# -ge 2 ]] || fail '--config-file requires a value'; CONFIG_FILE="$2"; shift 2 ;;
--install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;;
--config-root) [[ $# -ge 2 ]] || fail '--config-root requires a value'; CONFIG_ROOT="$2"; shift 2 ;;
--state-root) [[ $# -ge 2 ]] || fail '--state-root requires a value'; STATE_ROOT="$2"; shift 2 ;;
--log-root) [[ $# -ge 2 ]] || fail '--log-root requires a value'; LOG_ROOT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown option: $1" ;;
esac
done
require_root() {
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$(id -u)" != "0" ]]; then
fail 'Linux uninstall requires root'
fi
}
require_confirmation() {
[[ "$PURGE_DATABASE" == "1" ]] || fail '--purge-database is required for a clean reinstall'
[[ "$ASSUME_YES" == "1" ]] || fail '--yes is required to confirm database and file removal'
}
validate_remove_path() {
local path="$1"
[[ "$path" == /* && "$path" != "/" && ${#path} -ge 8 ]] \
|| fail "refusing unsafe removal path: $path"
[[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \
|| fail "refusing removal path with unsafe characters: $path"
local resolved
resolved="$(python3 - "$path" <<'PY'
import os
import sys
print(os.path.realpath(sys.argv[1]), end="")
PY
)" || fail "unable to resolve removal path: $path"
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$resolved" != "$path" ]]; then
fail "removal path must be canonical and cannot traverse links: $path"
fi
case "$resolved" in
/bin|/boot|/dev|/etc|/home|/lib|/lib64|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var)
fail "refusing unsafe removal path: $path"
;;
esac
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" ]]; then
case "$resolved" in
/etc/kaidi-erp|/var/lib/kaidi-erp|/var/log/kaidi-erp) ;;
/bin/*|/boot/*|/dev/*|/etc/*|/lib/*|/lib64/*|/proc/*|/root/*|/run/*|/sbin/*|/sys/*|/tmp/*|/usr/*|/var/*)
fail "refusing removal below a protected system directory: $path"
;;
esac
fi
printf '%s' "$resolved"
}
prepare_paths() {
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
INSTALL_ROOT="$(validate_remove_path "$INSTALL_ROOT")"
CONFIG_ROOT="$(validate_remove_path "$CONFIG_ROOT")"
STATE_ROOT="$(validate_remove_path "$STATE_ROOT")"
LOG_ROOT="$(validate_remove_path "$LOG_ROOT")"
local config_resolved
config_resolved="$(python3 - "$CONFIG_FILE" <<'PY'
import os
import sys
print(os.path.realpath(sys.argv[1]), end="")
PY
)" || fail "unable to resolve configuration path: $CONFIG_FILE"
[[ "$config_resolved" == "$CONFIG_ROOT/erp.env" ]] \
|| fail "configuration file must be $CONFIG_ROOT/erp.env"
CONFIG_FILE="$config_resolved"
}
read_config_value() {
local key="$1"
python3 - "$CONFIG_FILE" "$key" <<'PY'
import re
import shlex
import sys
path, wanted = sys.argv[1:]
pattern = re.compile(r"[A-Z][A-Z0-9_]*")
seen = set()
matches = []
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.rstrip("\r\n")
if not line or line.startswith("#") or "=" not in line:
continue
key, encoded = line.split("=", 1)
if not pattern.fullmatch(key):
raise SystemExit("invalid configuration key")
if key in seen:
raise SystemExit(f"duplicate configuration key: {key}")
seen.add(key)
if key != wanted:
continue
values = shlex.split(encoded, posix=True)
if len(values) != 1:
raise SystemExit(f"invalid value for {wanted}")
matches.append(values[0])
if len(matches) != 1:
raise SystemExit(f"missing configuration value: {wanted}")
print(matches[0], end="")
PY
}
verify_installation_identity() {
[[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] \
|| fail "configuration file is missing or unsafe: $CONFIG_FILE"
local configured_root configured_lock configured_state
configured_root="$(read_config_value ERP_INSTALL_ROOT)" \
|| fail 'unable to verify the configured installation root'
configured_lock="$(read_config_value ERP_INSTALL_LOCK_FILE)" \
|| fail 'unable to verify the configured installation state'
configured_root="$(validate_remove_path "$configured_root")"
configured_state="$(validate_remove_path "$(dirname "$configured_lock")")"
[[ "$configured_root" == "$INSTALL_ROOT" ]] \
|| fail 'requested installation root does not match erp.env'
[[ "$configured_state" == "$STATE_ROOT" ]] \
|| fail 'requested state root does not match erp.env'
}
purge_database() {
command -v psql >/dev/null 2>&1 || fail 'PostgreSQL client psql is required'
local host port database sslmode username password owner_check
host="$(read_config_value ERP_PGHOST)"
port="$(read_config_value ERP_PGPORT)"
database="$(read_config_value ERP_PGDATABASE)"
sslmode="$(read_config_value ERP_PGSSLMODE)"
username="$(read_config_value OA_DB_USERNAME)"
password="$(read_config_value OA_DB_PASSWORD)"
[[ "$host" != *[[:space:]]* && "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 ]] \
|| fail 'invalid database endpoint in configuration'
[[ "$database" =~ ^[A-Za-z_][A-Za-z0-9_-]{0,62}$ ]] || fail 'invalid database name in configuration'
[[ "$username" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,127}$ ]] || fail 'invalid database user in configuration'
case "$database" in postgres|template0|template1) fail "refusing to purge protected database: $database" ;; esac
case "$sslmode" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid database SSL mode' ;; esac
owner_check="$(PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -XAt \
-h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 \
-c "SELECT pg_get_userbyid(datdba) = current_user FROM pg_database WHERE datname = current_database()")" \
|| fail 'unable to verify database ownership'
[[ "$owner_check" == "t" ]] \
|| fail 'configured ERP account is not the database owner; database was not changed'
say "Purging PostgreSQL schema $database/public"
PGPASSWORD="$password" PGSSLMODE="$sslmode" psql -X \
-h "$host" -p "$port" -U "$username" -d "$database" -v ON_ERROR_STOP=1 <<'SQL'
DROP SCHEMA IF EXISTS public CASCADE;
SELECT format('CREATE SCHEMA public AUTHORIZATION %I', current_user) \gexec
SQL
}
stop_service() {
if command -v systemctl >/dev/null 2>&1; then
local load_state
if ! systemctl stop kaidi-erp.service >/dev/null 2>&1; then
load_state="$(systemctl show kaidi-erp.service --property=LoadState --value 2>/dev/null || true)"
[[ "$load_state" == "not-found" ]] \
|| fail 'unable to stop kaidi-erp.service; database and files were not changed'
fi
if systemctl is-active --quiet kaidi-erp.service; then
fail 'kaidi-erp.service is still active; database and files were not changed'
fi
fi
}
remove_service() {
if command -v systemctl >/dev/null 2>&1; then
systemctl disable kaidi-erp.service >/dev/null 2>&1 || true
fi
local unit_file="${ERP_UNINSTALL_SYSTEMD_UNIT_FILE:-/etc/systemd/system/kaidi-erp.service}"
if [[ "${ERP_UNINSTALL_TEST_MODE:-0}" != "1" && "$unit_file" != "/etc/systemd/system/kaidi-erp.service" ]]; then
fail 'custom systemd unit path is only available in test mode'
fi
rm -f -- "$unit_file"
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl reset-failed kaidi-erp.service >/dev/null 2>&1 || true
fi
}
remove_files() {
local path
for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do
[[ ! -e "$path" && ! -L "$path" ]] || rm -rf -- "$path"
done
}
main() {
require_root
require_confirmation
prepare_paths
verify_installation_identity
stop_service
purge_database
remove_service
remove_files
say 'Kaidi ERP service, files, state, logs, and public database schema were removed'
}
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
main "$@"
fi