#!/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