From 929d7c19bf4bd1d0f7e23f506dd62683d398a35d Mon Sep 17 00:00:00 2001 From: Ehsan <1883051+ehsanking@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:00:51 +0330 Subject: [PATCH 1/4] fix(installer): capture live db creds before reinstall teardown --- install.sh | 207 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 197 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index 5377d46..435951d 100755 --- a/install.sh +++ b/install.sh @@ -33,6 +33,10 @@ ADMIN_FORCE_PASSWORD_CHANGE=false UPGRADE_BACKUP_DIR="" CADDY_RUNTIME_VALIDATED=false LOCAL_PROXY_HEALTH_VALIDATED=false +REINSTALL_ENV_REUSED=false +REINSTALL_ADMIN_SECRET_RESTORED=false +REINSTALL_DB_ENV_RESTORED=false +declare -A REINSTALL_LIVE_DB_ENV=() log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } log_success() { echo -e "${GREEN}[OK]${NC} $1"; } @@ -42,6 +46,42 @@ log_step() { echo -e "\n${PURPLE}=== $1 ===${NC}"; } command_exists() { command -v "$1" >/dev/null 2>&1; } +compose_project_name() { + basename "$TARGET_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' +} + +postgres_named_volume() { + printf '%s_pgdata' "$(compose_project_name)" +} + +postgres_volume_exists() { + local volume_name + volume_name="$(postgres_named_volume)" + docker volume inspect "$volume_name" >/dev/null 2>&1 +} + +capture_reinstall_live_db_env() { + [ "$INSTALL_MODE" = "reinstall" ] || return 0 + command_exists docker || return 0 + + local db_container env_lines key value + db_container="$(docker ps -a --filter "name=^/elahe-db$" --format '{{.ID}}' 2>/dev/null | head -n1)" + [ -n "$db_container" ] || return 0 + + env_lines="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$db_container" 2>/dev/null || true)" + [ -n "$env_lines" ] || return 0 + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + POSTGRES_USER=*|POSTGRES_PASSWORD=*|POSTGRES_DB=*|APP_DB_USER=*|APP_DB_PASSWORD=*) + key="${line%%=*}" + value="${line#*=}" + REINSTALL_LIVE_DB_ENV["$key"]="$value" + ;; + esac + done <<< "$env_lines" +} + is_true() { case "${1:-}" in 1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;; @@ -329,10 +369,10 @@ choose_install_mode() { [ -f "$TARGET_DIR/.env" ] && env_exists=true [ -f "$TARGET_DIR/docker-compose.yml" ] && known_project_files=true - local compose_project_name - compose_project_name="$(basename "$TARGET_DIR" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '_')" + local detected_compose_project + detected_compose_project="$(compose_project_name)" - if command_exists docker && [ -n "$(docker ps -a --filter "label=com.docker.compose.project=${compose_project_name}" --format '{{.Names}}' 2>/dev/null)" ]; then + if command_exists docker && [ -n "$(docker ps -a --filter "label=com.docker.compose.project=${detected_compose_project}" --format '{{.Names}}' 2>/dev/null)" ]; then compose_project=true fi @@ -849,6 +889,10 @@ backup_upgrade_artifacts() { [ -f "$TARGET_DIR/docker-compose.yml" ] && cp "$TARGET_DIR/docker-compose.yml" "$backup_dir/docker-compose.yml" [ -f "$TARGET_DIR/compose.prod.yaml" ] && cp "$TARGET_DIR/compose.prod.yaml" "$backup_dir/compose.prod.yaml" [ -f "$TARGET_DIR/compose_prod_full.yml" ] && cp "$TARGET_DIR/compose_prod_full.yml" "$backup_dir/compose_prod_full.yml" + if [ -f "$TARGET_DIR/runtime/admin-bootstrap-password" ]; then + mkdir -p "$backup_dir/runtime" + cp "$TARGET_DIR/runtime/admin-bootstrap-password" "$backup_dir/runtime/admin-bootstrap-password" + fi UPGRADE_BACKUP_DIR="$backup_dir" log_success "Backup created: $backup_dir" @@ -909,6 +953,116 @@ sync_source_tree() { ) } +seed_reinstall_env_from_backup() { + [ "$INSTALL_MODE" = "reinstall" ] || return 0 + local key required_db_keys + required_db_keys=(POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB APP_DB_USER APP_DB_PASSWORD) + + local live_all_required=true + for key in "${required_db_keys[@]}"; do + if [ -z "${REINSTALL_LIVE_DB_ENV[$key]-}" ]; then + live_all_required=false + break + fi + done + + if [ "$live_all_required" = true ]; then + local live_db_keys + live_db_keys=(POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB APP_DB_USER APP_DB_PASSWORD) + for key in "${live_db_keys[@]}"; do + env_set_explicit "$key" "${REINSTALL_LIVE_DB_ENV[$key]}" + done + REINSTALL_DB_ENV_RESTORED=true + REINSTALL_ENV_REUSED=true + log_info "Reinstall mode: reusing database credentials captured from existing DB container." + return 0 + fi + + [ -n "$UPGRADE_BACKUP_DIR" ] || return 0 + + local backup_env_file + backup_env_file="$UPGRADE_BACKUP_DIR/.env" + [ -f "$backup_env_file" ] || return 0 + + declare -A backup_env=() + while IFS= read -r line || [ -n "$line" ]; do + if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$(trim_space "$line")" ]]; then + continue + fi + if [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then + backup_env["${line%%=*}"]="${line#*=}" + fi + done < "$backup_env_file" + + local db_keys + db_keys=(POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB APP_DB_USER APP_DB_PASSWORD APP_DB_SSLMODE DATABASE_URL MIGRATION_DATABASE_URL) + + for key in "${db_keys[@]}"; do + if [ -n "${backup_env[$key]-}" ]; then + env_set_explicit "$key" "${backup_env[$key]}" + fi + done + + local all_required_present=true + for key in "${required_db_keys[@]}"; do + if [ -z "${backup_env[$key]-}" ]; then + all_required_present=false + break + fi + done + if [ "$all_required_present" = true ]; then + REINSTALL_DB_ENV_RESTORED=true + fi + + local admin_keys + admin_keys=(ADMIN_USERNAME ADMIN_BOOTSTRAP_PASSWORD_FILE ADMIN_BOOTSTRAP_STRICT ADMIN_BOOTSTRAP_FORCE_PASSWORD_CHANGE ADMIN_BOOTSTRAP_RESET_EXISTING) + for key in "${admin_keys[@]}"; do + if [ -n "${backup_env[$key]-}" ]; then + env_set_explicit "$key" "${backup_env[$key]}" + REINSTALL_ENV_REUSED=true + fi + done + + if [ "$REINSTALL_DB_ENV_RESTORED" = true ]; then + REINSTALL_ENV_REUSED=true + log_info "Reinstall mode: reusing preserved database credentials from backup .env." + else + log_warn "Reinstall mode: backup .env is missing one or more required database credentials." + fi +} + +restore_reinstall_admin_secret() { + [ "$INSTALL_MODE" = "reinstall" ] || return 0 + [ -n "$UPGRADE_BACKUP_DIR" ] || return 0 + + local source_secret target_secret + source_secret="$UPGRADE_BACKUP_DIR/runtime/admin-bootstrap-password" + target_secret="$TARGET_DIR/runtime/admin-bootstrap-password" + [ -f "$source_secret" ] || return 0 + + mkdir -p "$TARGET_DIR/runtime" + cp "$source_secret" "$target_secret" + ensure_root_owned_600 "$target_secret" + REINSTALL_ADMIN_SECRET_RESTORED=true + log_info "Reinstall mode: restored preserved runtime admin bootstrap secret." +} + +guard_reinstall_db_state() { + [ "$INSTALL_MODE" = "reinstall" ] || return 0 + + if ! postgres_volume_exists; then + return 0 + fi + + if [ "$REINSTALL_DB_ENV_RESTORED" = true ]; then + return 0 + fi + + log_error "Reinstall refused: persistent PostgreSQL volume '$(postgres_named_volume)' exists, but compatible credentials were not restored from backup .env." + log_error "Action required: restore the previous .env (with POSTGRES_USER/POSTGRES_PASSWORD/APP_DB_USER/APP_DB_PASSWORD) into ${UPGRADE_BACKUP_DIR:-}/.env, or use upgrade mode." + exit 1 +} + configure_runtime_env() { log_step "Runtime environment" @@ -917,8 +1071,18 @@ configure_runtime_env() { local env_file="$TARGET_DIR/.env" load_env_file "$env_file" + seed_reinstall_env_from_backup + restore_reinstall_admin_secret + guard_reinstall_db_state - if [ "$INSTALL_MODE" = "fresh" ] || [ "$INSTALL_MODE" = "reinstall" ]; then + local should_prompt_admin=false + if [ "$INSTALL_MODE" = "fresh" ]; then + should_prompt_admin=true + elif [ "$INSTALL_MODE" = "reinstall" ] && ! postgres_volume_exists; then + should_prompt_admin=true + fi + + if [ "$should_prompt_admin" = true ]; then prompt_admin_credentials_fresh fi @@ -1021,21 +1185,37 @@ configure_runtime_env() { env_set_if_missing "ADMIN_BOOTSTRAP_STATE_DIR" "/app/runtime_state" if [ "$INSTALL_MODE" = "fresh" ] || [ "$INSTALL_MODE" = "reinstall" ]; then - env_set_if_missing "ADMIN_USERNAME" "$ADMIN_USERNAME_VALUE" + local bootstrap_admin_username + bootstrap_admin_username="${ADMIN_USERNAME_VALUE:-$(env_get ADMIN_USERNAME)}" + if [ -z "$bootstrap_admin_username" ]; then + log_error "ADMIN_USERNAME is missing for ${INSTALL_MODE} mode." + exit 1 + fi + env_set_if_missing "ADMIN_USERNAME" "$bootstrap_admin_username" local bootstrap_password_file_host="$TARGET_DIR/runtime/admin-bootstrap-password" mkdir -p "$TARGET_DIR/runtime" if [ ! -f "$bootstrap_password_file_host" ]; then + if [ -z "${ADMIN_PASSWORD_VALUE:-}" ]; then + log_error "Missing admin bootstrap password for reinstall. Restore runtime/admin-bootstrap-password from backup or reinstall without persistent state." + exit 1 + fi printf '%s\n' "$ADMIN_PASSWORD_VALUE" > "$bootstrap_password_file_host" ensure_root_owned_600 "$bootstrap_password_file_host" fi env_set_if_missing "ADMIN_BOOTSTRAP_PASSWORD_FILE" "/run/secrets/admin-bootstrap-password" - env_set_if_missing "ADMIN_BOOTSTRAP_STRICT" "true" - if [ "$ADMIN_FORCE_PASSWORD_CHANGE" = true ]; then - env_set_if_missing "ADMIN_BOOTSTRAP_FORCE_PASSWORD_CHANGE" "true" - else + if [ "$INSTALL_MODE" = "reinstall" ] && postgres_volume_exists; then + env_set_if_missing "ADMIN_BOOTSTRAP_STRICT" "false" env_set_if_missing "ADMIN_BOOTSTRAP_FORCE_PASSWORD_CHANGE" "false" + env_set_if_missing "ADMIN_BOOTSTRAP_RESET_EXISTING" "false" + else + env_set_if_missing "ADMIN_BOOTSTRAP_STRICT" "true" + if [ "$ADMIN_FORCE_PASSWORD_CHANGE" = true ]; then + env_set_if_missing "ADMIN_BOOTSTRAP_FORCE_PASSWORD_CHANGE" "true" + else + env_set_if_missing "ADMIN_BOOTSTRAP_FORCE_PASSWORD_CHANGE" "false" + fi + env_set_if_missing "ADMIN_BOOTSTRAP_RESET_EXISTING" "false" fi - env_set_if_missing "ADMIN_BOOTSTRAP_RESET_EXISTING" "false" else env_set_if_missing "ADMIN_BOOTSTRAP_STRICT" "false" env_set_if_missing "ADMIN_BOOTSTRAP_FORCE_PASSWORD_CHANGE" "false" @@ -1328,6 +1508,12 @@ print_summary() { echo "Admin bootstrap env vars are create-only by default and do not overwrite an existing admin user." echo "To reset an existing admin via env, set ADMIN_BOOTSTRAP_RESET_EXISTING=true for a one-time reset." fi + if [ "$INSTALL_MODE" = "reinstall" ] && [ "$REINSTALL_ENV_REUSED" = true ]; then + echo "Reinstall reused preserved .env credentials/settings from installer backup." + fi + if [ "$INSTALL_MODE" = "reinstall" ] && [ "$REINSTALL_ADMIN_SECRET_RESTORED" = true ]; then + echo "Reinstall restored runtime/admin-bootstrap-password from installer backup." + fi echo "No admin password was printed to terminal output." if [ "$CADDY_RUNTIME_VALIDATED" = true ]; then echo "Caddy runtime config validated inside container." @@ -1361,6 +1547,7 @@ main() { ensure_docker_ready ensure_install_directory backup_upgrade_artifacts + capture_reinstall_live_db_env stop_existing_stack_if_needed choose_source_ref sync_source_tree From 5eaf1180d750a07376fbcc657e3ec64f27434ca3 Mon Sep 17 00:00:00 2001 From: Ehsan <1883051+ehsanking@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:08:45 +0330 Subject: [PATCH 2/4] fix(installer): clarify reinstall source of reused db credentials --- install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index 435951d..f93f5fa 100755 --- a/install.sh +++ b/install.sh @@ -36,6 +36,7 @@ LOCAL_PROXY_HEALTH_VALIDATED=false REINSTALL_ENV_REUSED=false REINSTALL_ADMIN_SECRET_RESTORED=false REINSTALL_DB_ENV_RESTORED=false +REINSTALL_LIVE_DB_ENV_USED=false declare -A REINSTALL_LIVE_DB_ENV=() log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } @@ -974,6 +975,7 @@ seed_reinstall_env_from_backup() { done REINSTALL_DB_ENV_RESTORED=true REINSTALL_ENV_REUSED=true + REINSTALL_LIVE_DB_ENV_USED=true log_info "Reinstall mode: reusing database credentials captured from existing DB container." return 0 fi @@ -1508,7 +1510,9 @@ print_summary() { echo "Admin bootstrap env vars are create-only by default and do not overwrite an existing admin user." echo "To reset an existing admin via env, set ADMIN_BOOTSTRAP_RESET_EXISTING=true for a one-time reset." fi - if [ "$INSTALL_MODE" = "reinstall" ] && [ "$REINSTALL_ENV_REUSED" = true ]; then + if [ "$INSTALL_MODE" = "reinstall" ] && [ "$REINSTALL_LIVE_DB_ENV_USED" = true ]; then + echo "Reinstall reused DB credentials captured from existing DB container." + elif [ "$INSTALL_MODE" = "reinstall" ] && [ "$REINSTALL_ENV_REUSED" = true ]; then echo "Reinstall reused preserved .env credentials/settings from installer backup." fi if [ "$INSTALL_MODE" = "reinstall" ] && [ "$REINSTALL_ADMIN_SECRET_RESTORED" = true ]; then From 639179049fc1ac21d6832115860b474259fb86a8 Mon Sep 17 00:00:00 2001 From: Ehsan <1883051+ehsanking@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:12:35 +0330 Subject: [PATCH 3/4] fix(installer): clarify reinstall credential recovery guard --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index f93f5fa..b62dd3c 100755 --- a/install.sh +++ b/install.sh @@ -1060,8 +1060,8 @@ guard_reinstall_db_state() { return 0 fi - log_error "Reinstall refused: persistent PostgreSQL volume '$(postgres_named_volume)' exists, but compatible credentials were not restored from backup .env." - log_error "Action required: restore the previous .env (with POSTGRES_USER/POSTGRES_PASSWORD/APP_DB_USER/APP_DB_PASSWORD) into ${UPGRADE_BACKUP_DIR:-}/.env, or use upgrade mode." + log_error "Reinstall refused: persistent PostgreSQL volume '$(postgres_named_volume)' exists, but compatible DB credentials were not recovered." + log_error "Action required: restore the previous .env (with POSTGRES_USER/POSTGRES_PASSWORD/APP_DB_USER/APP_DB_PASSWORD) into ${UPGRADE_BACKUP_DIR:-}/.env, or run upgrade mode." exit 1 } From 10938ab2c9cccc551c89ba545673049eb0bd0aa4 Mon Sep 17 00:00:00 2001 From: Ehsan <1883051+ehsanking@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:15:53 +0330 Subject: [PATCH 4/4] chore(installer): document newline-safe compose name normalization --- install.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/install.sh b/install.sh index b62dd3c..9976421 100755 --- a/install.sh +++ b/install.sh @@ -48,6 +48,7 @@ log_step() { echo -e "\n${PURPLE}=== $1 ===${NC}"; } command_exists() { command -v "$1" >/dev/null 2>&1; } compose_project_name() { + # Avoid `tr -c` here: it can transform trailing newline into `_` and break volume name lookup. basename "$TARGET_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g' }