forked from hesabix/arc
3960 lines
144 KiB
Shell
Executable file
3960 lines
144 KiB
Shell
Executable file
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
IFS=$'\n\t'
|
||
|
||
# ============================================================================
|
||
# Hesabix - Comprehensive Open Source Accounting System
|
||
# ============================================================================
|
||
#
|
||
# Hesabix is a complete, modern accounting system with a powerful API
|
||
# (FastAPI + PostgreSQL) and a Flutter Web user interface.
|
||
#
|
||
# This software is distributed under the GNU General Public License v3.0.
|
||
# Full license text:
|
||
# http://www.gnu.org/licenses/gpl-3.0.txt
|
||
#
|
||
# Developers: Hesabix Team
|
||
# Website: https://hesabix.ir
|
||
# Repository: https://source.hesabix.ir/hesabix/arc.git
|
||
# Support: https://hesabix.ir/support
|
||
#
|
||
# ============================================================================
|
||
# Deployment Script
|
||
# ============================================================================
|
||
#
|
||
# This script installs and configures Hesabix automatically:
|
||
# - Clone from: https://source.hesabix.ir/hesabix/arc.git
|
||
# - Prompt for API and UI domains
|
||
# - Install prerequisites, database (PostgreSQL), backend (FastAPI),
|
||
# frontend (Flutter Web), Nginx, and SSL
|
||
#
|
||
# Usage:
|
||
# sudo bash deploy.sh
|
||
# # or
|
||
# API_DOMAIN=api.example.com UI_DOMAIN=app.example.com BRANCH=main DB_PASSWORD=secure_password sudo -E bash deploy.sh
|
||
#
|
||
# Notes:
|
||
# - Designed for Ubuntu 22.04+/Debian 12+
|
||
# - Minimum install RAM: ~5.5 GiB — see check_minimum_ram
|
||
# - Nginx/domains: after install, use `sudo hesabix -domains set` or `sudo hesabix -domains apply`;
|
||
# legacy: scripts/update_nginx_domains.sh. SSL: `sudo hesabix -ssl enable`. Let's Encrypt or SSL_LETSENCRYPT_LIVE in .deploy_env.
|
||
# - Web build API URL: auto http/https from certificate at
|
||
# /etc/letsencrypt/live/<API_DOMAIN>; for TLS without that path, export API_PUBLIC_SCHEME.
|
||
# Manual hesabix-api HTTP-only config can send https traffic to the wrong default 443 vhost (e.g. pgAdmin).
|
||
# - Resume from failure: if a step fails, re-run the script to continue from that step
|
||
# (completed steps are skipped using .deploy_state)
|
||
# - Saved inputs: last entered domain, branch, pgAdmin4 options, etc. are stored in .deploy_saved_vars
|
||
# and used as defaults on next run (override by env vars or leave blank to be prompted again).
|
||
# - For full re-run/upgrade (e.g. pull latest code and rebuild): use RESET_STATE=y
|
||
# - pip / Flutter mirrors: interactive choice in prompt_vars (scripts/mirror_config.sh) — Hesabix, official,
|
||
# China mirrors, pub-azs.ir, or custom URL. Non-interactive: PIP_MIRROR=hesabix|official|tuna|aliyun|custom
|
||
# and FLUTTER_MIRROR=hesabix|pub_azs|flutter_io_cn|tuna|sjtu|official|custom. Saved in .deploy_saved_vars and .deploy_env.
|
||
# Direct URL override: PIP_INDEX_URL, PUB_HOSTED_URL, FLUTTER_STORAGE_BASE_URL.
|
||
# - Flutter SDK tarball: shell.hesabix.ir (internal); pub packages use selected FLUTTER_MIRROR.
|
||
# - Flutter SDK git clone: official (GitHub) is tried first; if it fails, alternatives are tried (FLUTTER_SDK_GIT_URL if set, then Tsinghua, Gitee).
|
||
# - Flutter SDK: first try internal tarball (FLUTTER_SDK_TARBALL_URL_INTERNAL = shell.hesabix.ir/...), then snap, then git clone; pub packages via PUB_HOSTED_URL.
|
||
# Large tarball (~2GB): download uses --progress-bar, resume (-C -), FLUTTER_SDK_CONNECT_TIMEOUT (default 120s),
|
||
# FLUTTER_SDK_DOWNLOAD_MAX_TIME (default 0 = no overall time limit; was 120s and caused false failures).
|
||
# Unreliable links: FLUTTER_SDK_TARBALL_ATTEMPTS (default 3), post-download tar test, optional FLUTTER_SDK_TARBALL_SHA256,
|
||
# FLUTTER_SDK_CURL_HTTP11=1 (default) sends --http1.1 to reduce flaky proxy/CDN issues.
|
||
# - Flutter PATH: /etc/profile.d/hesabix-flutter.sh (+ one line in /etc/bash.bashrc for non-login interactive shells).
|
||
# - Ubuntu APT mirror (only when ID=ubuntu): UBUNTU_APT_MIRROR=keep|arvan|official (non-interactive);
|
||
# default keep. arvan uses http://mirror.arvancloud.ir/ubuntu for archive.ubuntu.com and security.ubuntu.com.
|
||
# official restores those URLs from Arvan (per-stanza: *-security → security.ubuntu.com).
|
||
# - Debian: mirror prompt skipped; sources unchanged.
|
||
# - PostgreSQL pgvector: scripts/ensure_pgvector.sh installs postgresql-N-pgvector when available
|
||
# (deploy install_prereqs + deploy_backend; hesabix -update before migrations). Non-fatal if missing.
|
||
# - AI voice chat (local STT/TTS): scripts/ensure_voice_chat.sh — optional at prompt (INSTALL_VOICE);
|
||
# libav dev packages + pip install -e ".[voice]" (no cloud speech APIs).
|
||
# - Apt/needrestart: by default NEEDRESTART_SUSPEND=1 during deploy so post-install service
|
||
# restarts (e.g. fwupd-refresh) do not run and fail on headless VPS. Set NEEDRESTART_SUSPEND=0
|
||
# to allow needrestart behavior.
|
||
#
|
||
# ============================================================================
|
||
|
||
REPO_URL="https://source.hesabix.ir/hesabix/arc.git"
|
||
APP_ROOT="/opt/hesabix"
|
||
# Internal Flutter SDK tarball mirror; pub packages use selected FLUTTER_MIRROR
|
||
FLUTTER_SDK_TARBALL_URL_INTERNAL="https://shell.hesabix.ir/flutter_linux_3.41.1-stable.tar.xz"
|
||
# Large SDK tarball; 0 = no overall download time limit (avoids premature abort)
|
||
: "${FLUTTER_SDK_CONNECT_TIMEOUT:=120}"
|
||
: "${FLUTTER_SDK_DOWNLOAD_MAX_TIME:=0}"
|
||
: "${FLUTTER_SDK_TARBALL_ATTEMPTS:=3}"
|
||
: "${FLUTTER_SDK_CURL_HTTP11:=1}"
|
||
STATE_FILE="${APP_ROOT}/.deploy_state"
|
||
LOG_FILE="${APP_ROOT}/deploy.log"
|
||
CHECK_MARK=$'\xE2\x9C\x94'
|
||
CROSS_MARK=$'\xE2\x9D\x8C'
|
||
WARNING_MARK=$'\xE2\x9A\xA0'
|
||
|
||
DEPLOY_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
# shellcheck source=scripts/api_public_scheme.sh
|
||
if [[ -r "${DEPLOY_SCRIPT_DIR}/scripts/api_public_scheme.sh" ]]; then
|
||
# shellcheck disable=SC1091
|
||
source "${DEPLOY_SCRIPT_DIR}/scripts/api_public_scheme.sh"
|
||
fi
|
||
if ! declare -F hesabix_resolve_api_public_scheme >/dev/null 2>&1; then
|
||
hesabix_resolve_api_public_scheme() {
|
||
if [[ -n "${API_DOMAIN:-}" ]] && [[ -d "/etc/letsencrypt/live/${API_DOMAIN}" ]]; then
|
||
printf '%s' "https"; return 0
|
||
fi
|
||
local s="${API_PUBLIC_SCHEME:-}"
|
||
s="${s,,}"
|
||
case "$s" in http|https) printf '%s' "$s"; return 0 ;; esac
|
||
printf '%s' "http"
|
||
}
|
||
fi
|
||
# shellcheck source=scripts/hesabix_python.sh
|
||
for _hesabix_py_lib in \
|
||
"${DEPLOY_SCRIPT_DIR}/scripts/hesabix_python.sh" \
|
||
"${APP_ROOT}/app/scripts/hesabix_python.sh"; do
|
||
if [[ -r "${_hesabix_py_lib}" ]]; then
|
||
# shellcheck disable=SC1090
|
||
source "${_hesabix_py_lib}"
|
||
break
|
||
fi
|
||
done
|
||
unset _hesabix_py_lib
|
||
if ! declare -F hesabix_resolve_backend_python >/dev/null 2>&1; then
|
||
# Standalone: curl shell.hesabix.ir/deploy.sh — no scripts/ beside installer.sh
|
||
# shellcheck disable=SC1091
|
||
source /dev/stdin <<'HESABIX_PYTHON_INLINE'
|
||
: "${HESABIX_MIN_PYTHON_MAJOR:=3}"
|
||
: "${HESABIX_MIN_PYTHON_MINOR:=11}"
|
||
hesabix_python_version_ge() {
|
||
local ver="${1#Python }"
|
||
ver="${ver%% *}"
|
||
local req_major="$2" req_minor="$3"
|
||
local major minor
|
||
IFS=. read -r major minor _ <<< "${ver}"
|
||
major=${major:-0}
|
||
minor=${minor:-0}
|
||
if [[ "${major}" -gt "${req_major}" ]]; then return 0; fi
|
||
if [[ "${major}" -lt "${req_major}" ]]; then return 1; fi
|
||
[[ "${minor}" -ge "${req_minor}" ]]
|
||
}
|
||
hesabix_python_cmd_version() {
|
||
local cmd="$1"
|
||
"$cmd" --version 2>&1 | awk '{print $2}'
|
||
}
|
||
hesabix_python_cmd_meets_minimum() {
|
||
local cmd="$1" ver
|
||
command -v "${cmd}" >/dev/null 2>&1 || return 1
|
||
ver=$(hesabix_python_cmd_version "${cmd}")
|
||
hesabix_python_version_ge "${ver}" "${HESABIX_MIN_PYTHON_MAJOR}" "${HESABIX_MIN_PYTHON_MINOR}"
|
||
}
|
||
hesabix_resolve_backend_python() {
|
||
local cmd ver path
|
||
if [[ -n "${HESABIX_PYTHON:-}" ]]; then
|
||
if [[ -x "${HESABIX_PYTHON}" ]] && hesabix_python_cmd_meets_minimum "${HESABIX_PYTHON}"; then
|
||
printf '%s' "${HESABIX_PYTHON}"
|
||
return 0
|
||
fi
|
||
return 1
|
||
fi
|
||
for cmd in python3.13 python3.12 python3.11 python3; do
|
||
if hesabix_python_cmd_meets_minimum "${cmd}"; then
|
||
path=$(command -v "${cmd}")
|
||
HESABIX_PYTHON="${path}"
|
||
printf '%s' "${path}"
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
hesabix_install_backend_python_packages() {
|
||
if hesabix_resolve_backend_python >/dev/null 2>&1; then return 0; fi
|
||
if ! command -v apt-get >/dev/null 2>&1; then return 1; fi
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
local minor pkgs=()
|
||
for minor in 12 11; do
|
||
pkgs=("python3.${minor}" "python3.${minor}-venv" "python3.${minor}-dev")
|
||
if apt-get install -y "${pkgs[@]}"; then
|
||
if command -v "python3.${minor}" >/dev/null 2>&1; then return 0; fi
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
hesabix_ensure_backend_python() {
|
||
if hesabix_resolve_backend_python >/dev/null 2>&1; then return 0; fi
|
||
hesabix_install_backend_python_packages || return 1
|
||
hesabix_resolve_backend_python >/dev/null 2>&1
|
||
}
|
||
hesabix_ensure_backend_venv() {
|
||
local api_dir="$1" python_bin="$2"
|
||
local venv_dir="${api_dir}/.venv"
|
||
local venv_py="${venv_dir}/bin/python"
|
||
HESABIX_VENV_RECREATED=0
|
||
if [[ ! -x "${python_bin}" ]]; then return 1; fi
|
||
if ! hesabix_python_cmd_meets_minimum "${python_bin}"; then return 1; fi
|
||
if [[ -d "${venv_dir}" ]] && [[ -x "${venv_py}" ]]; then
|
||
local venv_ver
|
||
venv_ver=$(hesabix_python_cmd_version "${venv_py}")
|
||
if ! hesabix_python_version_ge "${venv_ver}" "${HESABIX_MIN_PYTHON_MAJOR}" "${HESABIX_MIN_PYTHON_MINOR}"; then
|
||
rm -rf "${venv_dir}"
|
||
HESABIX_VENV_RECREATED=1
|
||
fi
|
||
fi
|
||
if [[ ! -d "${venv_dir}" ]]; then
|
||
"${python_bin}" -m venv "${venv_dir}" || return 1
|
||
HESABIX_VENV_RECREATED=1
|
||
fi
|
||
[[ -x "${venv_py}" ]]
|
||
}
|
||
HESABIX_PYTHON_INLINE
|
||
fi
|
||
# Load PyPI/Flutter mirror helpers: repo scripts/mirror_config.sh, or inline fallback for curl installer.
|
||
hesabix_load_mirror_config() {
|
||
if declare -F configure_pip_hesabix_mirror >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
local f
|
||
for f in \
|
||
"${DEPLOY_SCRIPT_DIR}/scripts/mirror_config.sh" \
|
||
"${APP_ROOT}/app/scripts/mirror_config.sh"; do
|
||
if [[ -r "${f}" ]]; then
|
||
# shellcheck disable=SC1090
|
||
source "${f}"
|
||
return 0
|
||
fi
|
||
done
|
||
# Standalone: curl shell.hesabix.ir/deploy.sh — no scripts/ beside installer.sh
|
||
# shellcheck disable=SC1091
|
||
source /dev/stdin <<'HESABIX_MIRROR_CONFIG_INLINE'
|
||
HESABIX_PIP_INDEX_URL="https://p.mirror.hesabix.ir/simple"
|
||
HESABIX_PIP_TRUSTED_HOST="p.mirror.hesabix.ir"
|
||
HESABIX_PUB_HOSTED_URL="https://f.mirror.hesabix.ir/pub"
|
||
HESABIX_FLUTTER_STORAGE_BASE_URL="https://f.mirror.hesabix.ir/gcs"
|
||
hesabix_mirror_log_info() {
|
||
if declare -F log_info >/dev/null 2>&1; then log_info "$@"; else echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; fi
|
||
}
|
||
hesabix_mirror_log_warning() {
|
||
if declare -F log_warning >/dev/null 2>&1; then log_warning "$@"
|
||
elif declare -F log_warn >/dev/null 2>&1; then log_warn "$@"
|
||
else echo "WARNING: $*" >&2; fi
|
||
}
|
||
hesabix_normalize_pip_index_url() {
|
||
local url="${1%/}"
|
||
case "${url}" in */simple) printf '%s' "${url}" ;; *) printf '%s/simple' "${url}" ;; esac
|
||
}
|
||
hesabix_pip_trusted_host_from_url() {
|
||
local index_url="$1"
|
||
[[ "${index_url}" == *"pypi.org"* ]] && return 0
|
||
echo "${index_url}" | sed -n 's|https\?://\([^/]*\).*|\1|p'
|
||
}
|
||
hesabix_set_pip_mirror_for_url() {
|
||
local index_url
|
||
index_url="$(hesabix_normalize_pip_index_url "$1")"
|
||
export PIP_INDEX_URL="${index_url}"
|
||
if [[ "${index_url}" != *"pypi.org"* ]]; then
|
||
export PIP_TRUSTED_HOST="$(hesabix_pip_trusted_host_from_url "${index_url}")"
|
||
else unset PIP_TRUSTED_HOST; fi
|
||
}
|
||
hesabix_resolve_pip_mirror_from_preset() {
|
||
local preset="${1:-hesabix}"; preset="${preset,,}"
|
||
case "${preset}" in
|
||
hesabix|default) hesabix_set_pip_mirror_for_url "${HESABIX_PIP_INDEX_URL}" ;;
|
||
official|pypi) hesabix_set_pip_mirror_for_url "https://pypi.org/simple" ;;
|
||
tuna|tsinghua) hesabix_set_pip_mirror_for_url "https://pypi.tuna.tsinghua.edu.cn/simple" ;;
|
||
aliyun) hesabix_set_pip_mirror_for_url "https://mirrors.aliyun.com/pypi/simple" ;;
|
||
custom)
|
||
if [[ -z "${PIP_INDEX_URL:-}" ]]; then
|
||
hesabix_mirror_log_warning "PIP_MIRROR=custom but PIP_INDEX_URL empty; using Hesabix."
|
||
PIP_MIRROR=hesabix; export PIP_MIRROR
|
||
hesabix_set_pip_mirror_for_url "${HESABIX_PIP_INDEX_URL}"; return 0
|
||
fi
|
||
hesabix_set_pip_mirror_for_url "${PIP_INDEX_URL}" ;;
|
||
*)
|
||
hesabix_mirror_log_warning "Unknown PIP_MIRROR='${preset}'; using Hesabix."
|
||
PIP_MIRROR=hesabix; export PIP_MIRROR
|
||
hesabix_set_pip_mirror_for_url "${HESABIX_PIP_INDEX_URL}" ;;
|
||
esac
|
||
}
|
||
hesabix_resolve_flutter_mirror_from_preset() {
|
||
local preset="${1:-hesabix}"; preset="${preset,,}"
|
||
case "${preset}" in
|
||
hesabix|default)
|
||
export PUB_HOSTED_URL="${HESABIX_PUB_HOSTED_URL}"
|
||
export FLUTTER_STORAGE_BASE_URL="${HESABIX_FLUTTER_STORAGE_BASE_URL}" ;;
|
||
pub_azs|pub-azs|azs)
|
||
export PUB_HOSTED_URL="https://pub-azs.ir"
|
||
export FLUTTER_STORAGE_BASE_URL="https://pub-azs.ir" ;;
|
||
flutter_io_cn|china|flutter-io)
|
||
export PUB_HOSTED_URL="https://pub.flutter-io.cn"
|
||
export FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn" ;;
|
||
tuna|tsinghua)
|
||
export PUB_HOSTED_URL="https://mirrors.tuna.tsinghua.edu.cn/dart-pub"
|
||
export FLUTTER_STORAGE_BASE_URL="https://mirrors.tuna.tsinghua.edu.cn/flutter" ;;
|
||
sjtu)
|
||
export PUB_HOSTED_URL="https://mirror.sjtu.edu.cn/dart-pub"
|
||
export FLUTTER_STORAGE_BASE_URL="https://mirror.sjtu.edu.cn" ;;
|
||
official|pubdev)
|
||
export PUB_HOSTED_URL="https://pub.dev"
|
||
export FLUTTER_STORAGE_BASE_URL="https://storage.googleapis.com" ;;
|
||
custom)
|
||
if [[ -z "${PUB_HOSTED_URL:-}" || -z "${FLUTTER_STORAGE_BASE_URL:-}" ]]; then
|
||
hesabix_mirror_log_warning "FLUTTER_MIRROR=custom but URLs missing; using Hesabix."
|
||
FLUTTER_MIRROR=hesabix; export FLUTTER_MIRROR
|
||
export PUB_HOSTED_URL="${HESABIX_PUB_HOSTED_URL}"
|
||
export FLUTTER_STORAGE_BASE_URL="${HESABIX_FLUTTER_STORAGE_BASE_URL}"; return 0
|
||
fi
|
||
export PUB_HOSTED_URL FLUTTER_STORAGE_BASE_URL ;;
|
||
*)
|
||
hesabix_mirror_log_warning "Unknown FLUTTER_MIRROR='${preset}'; using Hesabix."
|
||
FLUTTER_MIRROR=hesabix; export FLUTTER_MIRROR
|
||
export PUB_HOSTED_URL="${HESABIX_PUB_HOSTED_URL}"
|
||
export FLUTTER_STORAGE_BASE_URL="${HESABIX_FLUTTER_STORAGE_BASE_URL}" ;;
|
||
esac
|
||
}
|
||
hesabix_apply_pip_mirror_env() {
|
||
if [[ -n "${PIP_INDEX_URL:-}" ]]; then
|
||
hesabix_mirror_log_info "Using PyPI index from environment: PIP_INDEX_URL=${PIP_INDEX_URL}"
|
||
hesabix_set_pip_mirror_for_url "${PIP_INDEX_URL}"; return 0
|
||
fi
|
||
PIP_MIRROR="${PIP_MIRROR:-hesabix}"; export PIP_MIRROR
|
||
hesabix_resolve_pip_mirror_from_preset "${PIP_MIRROR}"
|
||
hesabix_mirror_log_info "Using PyPI mirror (${PIP_MIRROR}): ${PIP_INDEX_URL}"
|
||
}
|
||
hesabix_apply_flutter_mirror_env() {
|
||
if [[ -n "${PUB_HOSTED_URL:-}" && -n "${FLUTTER_STORAGE_BASE_URL:-}" ]]; then
|
||
export PUB_HOSTED_URL FLUTTER_STORAGE_BASE_URL
|
||
hesabix_mirror_log_info "Using Flutter mirrors from environment: PUB_HOSTED_URL=${PUB_HOSTED_URL}"; return 0
|
||
fi
|
||
FLUTTER_MIRROR="${FLUTTER_MIRROR:-hesabix}"; export FLUTTER_MIRROR
|
||
hesabix_resolve_flutter_mirror_from_preset "${FLUTTER_MIRROR}"
|
||
hesabix_mirror_log_info "Flutter pub/storage (${FLUTTER_MIRROR}): PUB_HOSTED_URL=${PUB_HOSTED_URL}"
|
||
}
|
||
hesabix_configure_pip_mirror() {
|
||
command -v python3 >/dev/null 2>&1 || return 0
|
||
hesabix_apply_pip_mirror_env
|
||
python3 -m pip config --user set global.index "${PIP_INDEX_URL}" 2>/dev/null || true
|
||
python3 -m pip config --user set global.index-url "${PIP_INDEX_URL}" 2>/dev/null || true
|
||
[[ -n "${PIP_TRUSTED_HOST:-}" ]] && python3 -m pip config --user set global.trusted-host "${PIP_TRUSTED_HOST}" 2>/dev/null || true
|
||
hesabix_mirror_log_info "pip user config: ${PIP_INDEX_URL}"
|
||
}
|
||
hesabix_prompt_pip_mirror() {
|
||
[[ -n "${PIP_INDEX_URL:-}" ]] && return 0
|
||
PIP_MIRROR="${PIP_MIRROR:-}"; PIP_MIRROR="${PIP_MIRROR,,}"
|
||
[[ -n "${PIP_MIRROR}" ]] && return 0
|
||
echo
|
||
echo "Python package index (pip / PyPI) — for backend dependencies:"
|
||
echo " [1] Hesabix — p.mirror.hesabix.ir (default; recommended for Iran)"
|
||
echo " [2] Official — pypi.org"
|
||
echo " [3] Tsinghua mirror (China) — pypi.tuna.tsinghua.edu.cn"
|
||
echo " [4] Aliyun mirror (China) — mirrors.aliyun.com/pypi"
|
||
echo " [5] Custom URL"
|
||
read -rp "Choose [1-5] (default 1): " _pip_choice
|
||
_pip_choice=${_pip_choice:-1}
|
||
case "${_pip_choice}" in
|
||
2) PIP_MIRROR=official ;; 3) PIP_MIRROR=tuna ;; 4) PIP_MIRROR=aliyun ;;
|
||
5) PIP_MIRROR=custom; read -rp "Index URL (e.g. https://my.mirror/simple): " _pip_custom
|
||
[[ -n "${_pip_custom}" ]] && { PIP_INDEX_URL="$(hesabix_normalize_pip_index_url "${_pip_custom}")"; export PIP_INDEX_URL; } ;;
|
||
*) PIP_MIRROR=hesabix ;;
|
||
esac
|
||
export PIP_MIRROR
|
||
}
|
||
hesabix_prompt_flutter_mirror() {
|
||
[[ -n "${PUB_HOSTED_URL:-}" && -n "${FLUTTER_STORAGE_BASE_URL:-}" ]] && return 0
|
||
FLUTTER_MIRROR="${FLUTTER_MIRROR:-}"; FLUTTER_MIRROR="${FLUTTER_MIRROR,,}"
|
||
[[ -n "${FLUTTER_MIRROR}" ]] && return 0
|
||
echo
|
||
echo "Flutter/Dart package mirror (pub get and engine) — for UI build:"
|
||
echo " [1] Hesabix — f.mirror.hesabix.ir (default; recommended for Iran)"
|
||
echo " [2] pub-azs.ir — direct (Iran)"
|
||
echo " [3] China — pub.flutter-io.cn / storage.flutter-io.cn"
|
||
echo " [4] Tsinghua mirror (China)"
|
||
echo " [5] SJTU mirror (China)"
|
||
echo " [6] Official — pub.dev / storage.googleapis.com"
|
||
echo " [7] Custom URLs"
|
||
read -rp "Choose [1-7] (default 1): " _flutter_choice
|
||
_flutter_choice=${_flutter_choice:-1}
|
||
case "${_flutter_choice}" in
|
||
2) FLUTTER_MIRROR=pub_azs ;; 3) FLUTTER_MIRROR=flutter_io_cn ;; 4) FLUTTER_MIRROR=tuna ;;
|
||
5) FLUTTER_MIRROR=sjtu ;; 6) FLUTTER_MIRROR=official ;;
|
||
7) FLUTTER_MIRROR=custom
|
||
read -rp "PUB_HOSTED_URL (e.g. https://pub.example): " _pub_url
|
||
read -rp "FLUTTER_STORAGE_BASE_URL (e.g. https://storage.example): " _storage_url
|
||
[[ -n "${_pub_url}" && -n "${_storage_url}" ]] && {
|
||
export PUB_HOSTED_URL="${_pub_url%/}"; export FLUTTER_STORAGE_BASE_URL="${_storage_url%/}"; } ;;
|
||
*) FLUTTER_MIRROR=hesabix ;;
|
||
esac
|
||
export FLUTTER_MIRROR
|
||
}
|
||
hesabix_mirror_summary_pip() {
|
||
hesabix_apply_pip_mirror_env >/dev/null 2>&1 || true
|
||
if [[ "${PIP_MIRROR:-}" == "custom" ]]; then
|
||
echo " • PyPI (pip): custom — ${PIP_INDEX_URL:-}"
|
||
else echo " • PyPI (pip): ${PIP_MIRROR:-hesabix} — ${PIP_INDEX_URL:-}"; fi
|
||
}
|
||
hesabix_mirror_summary_flutter() {
|
||
hesabix_apply_flutter_mirror_env >/dev/null 2>&1 || true
|
||
if [[ "${FLUTTER_MIRROR:-}" == "custom" ]]; then
|
||
echo " • Flutter pub: custom — ${PUB_HOSTED_URL:-}"
|
||
echo " • Flutter storage: ${FLUTTER_STORAGE_BASE_URL:-}"
|
||
else
|
||
echo " • Flutter pub: ${FLUTTER_MIRROR:-hesabix} — ${PUB_HOSTED_URL:-}"
|
||
echo " • Flutter storage: ${FLUTTER_STORAGE_BASE_URL:-}"
|
||
fi
|
||
}
|
||
configure_pip_hesabix_mirror() { hesabix_configure_pip_mirror "$@"; }
|
||
set_pip_mirror_env() { hesabix_apply_pip_mirror_env "$@"; }
|
||
set_flutter_mirror_env() { hesabix_apply_flutter_mirror_env "$@"; }
|
||
HESABIX_MIRROR_CONFIG_INLINE
|
||
}
|
||
hesabix_load_mirror_config
|
||
|
||
# Initialize log file
|
||
init_log_file() {
|
||
mkdir -p "${APP_ROOT}"
|
||
local log_header="========================================
|
||
Hesabix Deployment Log
|
||
Started: $(date '+%Y-%m-%d %H:%M:%S')
|
||
========================================"
|
||
echo "${log_header}" > "${LOG_FILE}"
|
||
chmod 644 "${LOG_FILE}"
|
||
}
|
||
|
||
# Logging functions
|
||
log_info() {
|
||
local message="$1"
|
||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||
echo "[INFO] ${timestamp} - ${message}" >> "${LOG_FILE}"
|
||
echo "${message}"
|
||
}
|
||
|
||
log_success() {
|
||
local message="$1"
|
||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||
echo "[SUCCESS] ${timestamp} - ${message}" >> "${LOG_FILE}"
|
||
echo "$CHECK_MARK ${message}"
|
||
}
|
||
|
||
log_warning() {
|
||
local message="$1"
|
||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||
echo "[WARNING] ${timestamp} - ${message}" >> "${LOG_FILE}"
|
||
echo "$WARNING_MARK ${message}"
|
||
}
|
||
|
||
log_error() {
|
||
local message="$1"
|
||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||
echo "[ERROR] ${timestamp} - ${message}" >> "${LOG_FILE}"
|
||
echo "$CROSS_MARK ${message}" >&2
|
||
}
|
||
|
||
log_step() {
|
||
local message="$1"
|
||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||
echo "[STEP] ${timestamp} - ${message}" >> "${LOG_FILE}"
|
||
echo ">> ${message}"
|
||
}
|
||
|
||
# Generate random password if not provided
|
||
generate_password() {
|
||
openssl rand -base64 32 | tr -d "=+/" | cut -c1-25
|
||
}
|
||
|
||
# Calculate optimal worker count based on CPU cores
|
||
calculate_optimal_workers() {
|
||
local cpu_cores
|
||
if command -v nproc >/dev/null 2>&1; then
|
||
cpu_cores=$(nproc)
|
||
elif [[ -f /proc/cpuinfo ]]; then
|
||
cpu_cores=$(grep -c "^processor" /proc/cpuinfo)
|
||
else
|
||
cpu_cores=4 # Default fallback
|
||
fi
|
||
|
||
# Formula: (2 * CPU cores) + 1 for optimal performance
|
||
echo $((2 * cpu_cores + 1))
|
||
}
|
||
|
||
# Calculate optimal database pool settings based on worker count
|
||
calculate_db_pool_settings() {
|
||
local workers=$1
|
||
local pool_size max_overflow
|
||
|
||
# Base pool size per worker: 20 connections
|
||
# Max overflow per worker: 30 connections
|
||
# Total per worker: 50 connections
|
||
# With safety margin: use 80% of calculated value
|
||
pool_size=$((workers * 20))
|
||
max_overflow=$((workers * 30))
|
||
|
||
# Set reasonable limits (min 20, max 200 for pool_size)
|
||
if [[ $pool_size -lt 20 ]]; then
|
||
pool_size=20
|
||
elif [[ $pool_size -gt 200 ]]; then
|
||
pool_size=200
|
||
fi
|
||
|
||
# Set reasonable limits for max_overflow (min 30, max 300)
|
||
if [[ $max_overflow -lt 30 ]]; then
|
||
max_overflow=30
|
||
elif [[ $max_overflow -gt 300 ]]; then
|
||
max_overflow=300
|
||
fi
|
||
|
||
echo "${pool_size}:${max_overflow}"
|
||
}
|
||
|
||
# MemTotal from /proc/meminfo (kilobytes)
|
||
get_mem_total_kb() {
|
||
if [[ -r /proc/meminfo ]]; then
|
||
awk '/^MemTotal:/ {print int($2); exit}' /proc/meminfo 2>/dev/null || echo "0"
|
||
else
|
||
echo "0"
|
||
fi
|
||
}
|
||
|
||
# Minimum 5.5 GiB RAM required for Hesabix install
|
||
check_minimum_ram() {
|
||
local mem_kb min_kb mem_mb min_mb
|
||
mem_kb=$(get_mem_total_kb)
|
||
if [[ ! "${mem_kb}" =~ ^[0-9]+$ ]] || [[ "${mem_kb}" -eq 0 ]]; then
|
||
log_error "Cannot read RAM from /proc/meminfo. Installation aborted."
|
||
exit 1
|
||
fi
|
||
# 5.5 GiB = 5.5 * 1024 * 1024 kB
|
||
min_kb=$(( (11 * 1024 * 1024) / 2 ))
|
||
mem_mb=$((mem_kb / 1024))
|
||
min_mb=$((min_kb / 1024))
|
||
if [[ "${mem_kb}" -lt "${min_kb}" ]]; then
|
||
log_error "Hesabix requires at least ${min_mb} MB RAM (~5.5 GiB). MemTotal is ~${mem_mb} MB."
|
||
log_error "Upgrade the server or add sufficient swap/RAM, then run again."
|
||
exit 1
|
||
fi
|
||
log_success "RAM check passed: ~${mem_mb} MB (minimum required: ${min_mb} MB)"
|
||
}
|
||
|
||
# Set PostgreSQL memory profile variables from MemTotal (kB)
|
||
assign_pg_memory_profile_from_mem_kb() {
|
||
local mem_kb="${1:-0}"
|
||
if [[ ! "${mem_kb}" =~ ^[0-9]+$ ]]; then
|
||
mem_kb=0
|
||
fi
|
||
# Thresholds in kB; ~7.5GiB tier for advertised "8GB" VPS (MemTotal ~7.8GiB)
|
||
# 16GiB=16777216, 32GiB=33554432
|
||
if [[ "${mem_kb}" -ge 33554432 ]]; then
|
||
PG_SHARED_BUFFERS="8GB"
|
||
PG_EFFECTIVE_CACHE_SIZE="24GB"
|
||
PG_WORK_MEM="16MB"
|
||
PG_MAINTENANCE_WORK_MEM="512MB"
|
||
PG_MAX_CONN_CAP=800
|
||
elif [[ "${mem_kb}" -ge 16777216 ]]; then
|
||
PG_SHARED_BUFFERS="4GB"
|
||
PG_EFFECTIVE_CACHE_SIZE="12GB"
|
||
PG_WORK_MEM="12MB"
|
||
PG_MAINTENANCE_WORK_MEM="256MB"
|
||
PG_MAX_CONN_CAP=500
|
||
elif [[ "${mem_kb}" -ge 7864320 ]]; then
|
||
PG_SHARED_BUFFERS="1GB"
|
||
PG_EFFECTIVE_CACHE_SIZE="6GB"
|
||
PG_WORK_MEM="8MB"
|
||
PG_MAINTENANCE_WORK_MEM="128MB"
|
||
PG_MAX_CONN_CAP=280
|
||
else
|
||
PG_SHARED_BUFFERS="512MB"
|
||
PG_EFFECTIVE_CACHE_SIZE="3GB"
|
||
PG_WORK_MEM="4MB"
|
||
PG_MAINTENANCE_WORK_MEM="64MB"
|
||
PG_MAX_CONN_CAP=200
|
||
fi
|
||
}
|
||
|
||
# Reduce workers and pool so total DB connections stay within a RAM-based cap
|
||
tune_deployment_for_system_ram() {
|
||
local mem_kb need pool_settings cap
|
||
mem_kb=$(get_mem_total_kb)
|
||
if [[ ! "${mem_kb}" =~ ^[0-9]+$ ]] || [[ "${mem_kb}" -eq 0 ]]; then
|
||
log_warning "Could not read RAM; skipping automatic worker/pool tuning."
|
||
return 0
|
||
fi
|
||
assign_pg_memory_profile_from_mem_kb "${mem_kb}"
|
||
cap="${PG_MAX_CONN_CAP}"
|
||
need=$(( UVICORN_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) + 100 ))
|
||
while [[ "${need}" -gt "${cap}" ]] && [[ "${UVICORN_WORKERS}" -gt 1 ]]; do
|
||
UVICORN_WORKERS=$((UVICORN_WORKERS - 1))
|
||
pool_settings=$(calculate_db_pool_settings "${UVICORN_WORKERS}")
|
||
DB_POOL_SIZE=$(echo "${pool_settings}" | cut -d: -f1)
|
||
DB_MAX_OVERFLOW=$(echo "${pool_settings}" | cut -d: -f2)
|
||
need=$(( UVICORN_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) + 100 ))
|
||
log_warning "Reduced UVICORN_WORKERS to ${UVICORN_WORKERS} to fit PostgreSQL connection cap (~${cap}) for this RAM."
|
||
done
|
||
while [[ "${need}" -gt "${cap}" ]]; do
|
||
if [[ "${DB_POOL_SIZE}" -gt 20 ]]; then
|
||
DB_POOL_SIZE=$((DB_POOL_SIZE - 10))
|
||
elif [[ "${DB_MAX_OVERFLOW}" -gt 30 ]]; then
|
||
DB_MAX_OVERFLOW=$((DB_MAX_OVERFLOW - 10))
|
||
else
|
||
log_warning "Required connections (${need}) exceed suggested cap (${cap}); PostgreSQL max_connections will be set above cap."
|
||
break
|
||
fi
|
||
need=$(( UVICORN_WORKERS * (DB_POOL_SIZE + DB_MAX_OVERFLOW) + 100 ))
|
||
log_warning "Reduced DB_POOL_SIZE/DB_MAX_OVERFLOW to fit RAM budget (approx. connections needed: ${need})."
|
||
done
|
||
log_info "Final RAM-based tuning: workers=${UVICORN_WORKERS}, pool_size=${DB_POOL_SIZE}, max_overflow=${DB_MAX_OVERFLOW}, suggested PG connection cap≈${cap}"
|
||
}
|
||
|
||
# Write conf.d/hesabix-optimization.conf from RAM profile and computed load
|
||
write_postgresql_hesabix_optimization_conf() {
|
||
local pg_version="$1"
|
||
local mem_kb max_conn pg_conf_dest tmpf workers pool ov
|
||
mem_kb=$(get_mem_total_kb)
|
||
assign_pg_memory_profile_from_mem_kb "${mem_kb}"
|
||
workers="${UVICORN_WORKERS:-1}"
|
||
pool="${DB_POOL_SIZE:-20}"
|
||
ov="${DB_MAX_OVERFLOW:-30}"
|
||
max_conn=$(( workers * (pool + ov) + 100 ))
|
||
if [[ "${max_conn}" -lt 100 ]]; then
|
||
max_conn=100
|
||
fi
|
||
if [[ "${max_conn}" -lt $((pool + ov + 20)) ]]; then
|
||
max_conn=$((pool + ov + 20))
|
||
fi
|
||
pg_conf_dest="/etc/postgresql/${pg_version}/main/conf.d/hesabix-optimization.conf"
|
||
sudo mkdir -p "/etc/postgresql/${pg_version}/main/conf.d"
|
||
tmpf="$(mktemp)"
|
||
{
|
||
echo "# Generated by Hesabix deploy.sh — do not edit by hand (re-run deploy or adjust deploy.sh)."
|
||
echo "# MemTotal ~ $((mem_kb / 1024)) MB — profile shared_buffers=${PG_SHARED_BUFFERS}"
|
||
echo ""
|
||
echo "# Hesabix app: uvicorn workers × (pool_size + max_overflow) + headroom"
|
||
echo "max_connections = ${max_conn}"
|
||
echo ""
|
||
echo "shared_buffers = ${PG_SHARED_BUFFERS}"
|
||
echo "effective_cache_size = ${PG_EFFECTIVE_CACHE_SIZE}"
|
||
echo "work_mem = ${PG_WORK_MEM}"
|
||
echo "maintenance_work_mem = ${PG_MAINTENANCE_WORK_MEM}"
|
||
echo ""
|
||
echo "random_page_cost = 1.1"
|
||
} > "${tmpf}"
|
||
sudo install -o postgres -g postgres -m 0644 "${tmpf}" "${pg_conf_dest}"
|
||
rm -f "${tmpf}"
|
||
log_success "PostgreSQL optimization config written: ${pg_conf_dest}"
|
||
}
|
||
|
||
# Detect major PostgreSQL version without a running server (rewrite conf before start)
|
||
detect_postgresql_major_version_for_config() {
|
||
local v d
|
||
if command -v pg_lsclusters >/dev/null 2>&1; then
|
||
v=$(pg_lsclusters 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1; exit}')
|
||
if [[ -n "${v}" ]]; then
|
||
echo "${v}"
|
||
return 0
|
||
fi
|
||
fi
|
||
shopt -s nullglob
|
||
for d in /etc/postgresql/*/main; do
|
||
[[ -d "${d}" ]] || continue
|
||
v=$(basename "$(dirname "${d}")")
|
||
if [[ "${v}" =~ ^[0-9]+$ ]]; then
|
||
echo "${v}"
|
||
shopt -u nullglob
|
||
return 0
|
||
fi
|
||
done
|
||
shopt -u nullglob
|
||
return 1
|
||
}
|
||
|
||
# Reload PostgreSQL after conf.d change (postgresql@VERSION-main or meta service)
|
||
restart_postgresql_after_config_change() {
|
||
local pgv="${1:-}"
|
||
local meta_svc="${2:-}"
|
||
local ctl_out=""
|
||
[[ -z "${pgv}" ]] && return 0
|
||
systemctl daemon-reload 2>/dev/null || true
|
||
systemctl enable "postgresql@${pgv}-main" 2>/dev/null || true
|
||
if [[ -n "${meta_svc}" ]] && [[ "${meta_svc}" != "postgresql@${pgv}-main" ]]; then
|
||
systemctl restart "${meta_svc}" 2>/dev/null || true
|
||
fi
|
||
if systemctl restart "postgresql@${pgv}-main" 2>/dev/null; then
|
||
sleep 3
|
||
return 0
|
||
fi
|
||
log_warning "systemctl restart postgresql@${pgv}-main failed; trying pg_ctlcluster..."
|
||
if command -v pg_ctlcluster >/dev/null 2>&1; then
|
||
ctl_out=$(pg_ctlcluster "${pgv}" main start 2>&1) || true
|
||
[[ -n "${ctl_out}" ]] && log_info "${ctl_out}"
|
||
fi
|
||
systemctl restart postgresql 2>/dev/null || true
|
||
sleep 3
|
||
}
|
||
|
||
# Validate domain format
|
||
validate_domain() {
|
||
local domain="$1"
|
||
if [[ ! "${domain}" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ ]]; then
|
||
echo "$CROSS_MARK Invalid domain format: ${domain}"
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# Check if service is running
|
||
check_service() {
|
||
local service="$1"
|
||
if systemctl is-active --quiet "${service}"; then
|
||
return 0
|
||
else
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# Wait for PostgreSQL to be ready
|
||
wait_for_db() {
|
||
local max_attempts=45
|
||
local attempt=0
|
||
log_info "Waiting for PostgreSQL to be ready..."
|
||
while [ $attempt -lt $max_attempts ]; do
|
||
if command -v pg_isready >/dev/null 2>&1 && pg_isready -q 2>/dev/null; then
|
||
log_success "PostgreSQL is ready (pg_isready)"
|
||
return 0
|
||
fi
|
||
if sudo -u postgres psql -c "SELECT 1" >/dev/null 2>&1; then
|
||
log_success "PostgreSQL is ready"
|
||
return 0
|
||
fi
|
||
attempt=$((attempt + 1))
|
||
sleep 2
|
||
done
|
||
log_error "Database not ready after $max_attempts attempts"
|
||
log_error "Details: journalctl -xeu postgresql@*-main.service --no-pager | tail -100"
|
||
return 1
|
||
}
|
||
|
||
# Create or alter PostgreSQL role hesabix with password (any characters; uses dollar-quoting + format %L).
|
||
apply_postgres_hesabix_password() {
|
||
DB_PASSWORD="${DB_PASSWORD}" python3 <<'PY'
|
||
import os, subprocess, secrets
|
||
pw = os.environ["DB_PASSWORD"]
|
||
for _ in range(32):
|
||
delim = "h" + secrets.token_hex(16)
|
||
boundary = f"${delim}$"
|
||
if boundary not in pw:
|
||
break
|
||
else:
|
||
raise SystemExit("Could not pick a safe dollar-quote delimiter for the database password")
|
||
lit = boundary + pw + boundary
|
||
sql = (
|
||
"DO $do$\n"
|
||
"BEGIN\n"
|
||
" IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'hesabix') THEN\n"
|
||
" EXECUTE format('CREATE USER hesabix WITH PASSWORD %L', " + lit + ");\n"
|
||
" ELSE\n"
|
||
" EXECUTE format('ALTER USER hesabix WITH PASSWORD %L', " + lit + ");\n"
|
||
" END IF;\n"
|
||
"END\n"
|
||
"$do$;"
|
||
)
|
||
subprocess.run(
|
||
["sudo", "-u", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-c", sql],
|
||
check=True,
|
||
)
|
||
PY
|
||
}
|
||
|
||
# Merge production keys into hesabixAPI .env (values with special chars are quoted for dotenv).
|
||
merge_hesabix_api_env_file() {
|
||
local env_file="$1"
|
||
export MERGE_ENV_PATH="${env_file}"
|
||
export MERGE_DB_PASSWORD="${DB_PASSWORD}"
|
||
export MERGE_DB_POOL_SIZE="${DB_POOL_SIZE}"
|
||
export MERGE_DB_MAX_OVERFLOW="${DB_MAX_OVERFLOW}"
|
||
python3 <<'PY'
|
||
import os, re
|
||
path = os.environ["MERGE_ENV_PATH"]
|
||
|
||
def fmt_val(v: str) -> str:
|
||
if v is None:
|
||
return ""
|
||
if re.search(r'[\s#"\'\\]', v) or v.startswith("#"):
|
||
return '"' + v.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
|
||
return v
|
||
|
||
updates = {
|
||
"ENVIRONMENT": "production",
|
||
"DEBUG": "false",
|
||
"DB_USER": "hesabix",
|
||
"DB_PASSWORD": os.environ["MERGE_DB_PASSWORD"],
|
||
"DB_HOST": "127.0.0.1",
|
||
"DB_PORT": "5432",
|
||
"DB_NAME": "hesabix",
|
||
"LOG_LEVEL": "INFO",
|
||
"DB_POOL_SIZE": os.environ["MERGE_DB_POOL_SIZE"],
|
||
"DB_MAX_OVERFLOW": os.environ["MERGE_DB_MAX_OVERFLOW"],
|
||
"DB_POOL_TIMEOUT": "30",
|
||
"DB_POOL_RECYCLE": "300",
|
||
"CORS_ALLOWED_ORIGINS": '["*"]',
|
||
}
|
||
lines = []
|
||
if os.path.isfile(path):
|
||
with open(path, encoding="utf-8", errors="replace") as f:
|
||
lines = f.readlines()
|
||
keys_done = set()
|
||
key_re = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=")
|
||
out = []
|
||
for line in lines:
|
||
m = key_re.match(line)
|
||
if m and m.group(1) in updates:
|
||
k = m.group(1)
|
||
out.append(f"{k}={fmt_val(updates[k])}\n")
|
||
keys_done.add(k)
|
||
else:
|
||
out.append(line)
|
||
for k, v in updates.items():
|
||
if k not in keys_done:
|
||
out.append(f"{k}={fmt_val(v)}\n")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.writelines(out)
|
||
PY
|
||
}
|
||
|
||
# Check disk space (requires at least 2GB free)
|
||
check_disk_space() {
|
||
local available_space
|
||
local min_avail=999999999999
|
||
local space
|
||
for mnt in / "${APP_ROOT}"; do
|
||
[[ -d "${mnt}" ]] || continue
|
||
space=$(df -P "${mnt}" 2>/dev/null | tail -1 | awk '{print $4}')
|
||
[[ "${space}" =~ ^[0-9]+$ ]] || continue
|
||
if [ "${space}" -lt "${min_avail}" ]; then
|
||
min_avail="${space}"
|
||
fi
|
||
done
|
||
available_space="${min_avail}"
|
||
if [[ ! "${available_space}" =~ ^[0-9]+$ ]]; then
|
||
log_warning "Could not read free disk space; skipping disk check."
|
||
return 0
|
||
fi
|
||
if [ "$available_space" -lt 2097152 ]; then
|
||
log_warning "Low disk space (less than 2GB). This may cause issues."
|
||
read -rp "Continue anyway? (y/N): " continue_anyway
|
||
if [[ ! "${continue_anyway}" =~ ^[Yy]$ ]]; then
|
||
log_error "Installation aborted by user due to low disk space"
|
||
exit 1
|
||
fi
|
||
log_info "User chose to continue despite low disk space"
|
||
fi
|
||
}
|
||
|
||
require_cmd() {
|
||
if ! command -v "$1" >/dev/null 2>&1; then
|
||
echo "$CROSS_MARK Required tool not found: $1"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# Check if OS is Debian or Ubuntu
|
||
check_os_compatibility() {
|
||
if [[ ! -f /etc/os-release ]]; then
|
||
log_error "Cannot detect operating system. /etc/os-release not found."
|
||
log_error "This script supports only Debian and Ubuntu."
|
||
exit 1
|
||
fi
|
||
|
||
# Source os-release to get distribution info
|
||
# shellcheck disable=SC1091
|
||
source /etc/os-release
|
||
|
||
local os_id="${ID:-}"
|
||
local os_id_like="${ID_LIKE:-}"
|
||
|
||
# Check if it's Ubuntu or Debian
|
||
if [[ "${os_id}" == "ubuntu" ]] || [[ "${os_id}" == "debian" ]]; then
|
||
log_success "Detected compatible OS: ${PRETTY_NAME:-${os_id}}"
|
||
return 0
|
||
fi
|
||
|
||
# Check ID_LIKE for compatibility (e.g., Ubuntu is based on Debian)
|
||
if [[ "${os_id_like}" == *"debian"* ]] || [[ "${os_id_like}" == *"ubuntu"* ]]; then
|
||
log_success "Detected compatible OS: ${PRETTY_NAME:-${os_id}} (based on Debian/Ubuntu)"
|
||
return 0
|
||
fi
|
||
|
||
log_error "Unsupported operating system detected: ${PRETTY_NAME:-${os_id}}"
|
||
log_error "This script supports only Debian and Ubuntu distributions."
|
||
log_error "Detected OS ID: ${os_id}"
|
||
if [[ -n "${os_id_like}" ]]; then
|
||
log_error "OS ID_LIKE: ${os_id_like}"
|
||
fi
|
||
exit 1
|
||
}
|
||
|
||
# State tracking functions for resume capability
|
||
mark_step_completed() {
|
||
local step="$1"
|
||
mkdir -p "${APP_ROOT}"
|
||
echo "${step}" >> "${STATE_FILE}"
|
||
}
|
||
|
||
check_step_completed() {
|
||
local step="$1"
|
||
if [[ -f "${STATE_FILE}" ]] && grep -q "^${step}$" "${STATE_FILE}"; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
clear_deployment_state() {
|
||
if [[ -f "${STATE_FILE}" ]]; then
|
||
rm -f "${STATE_FILE}"
|
||
fi
|
||
}
|
||
|
||
# Load saved deploy inputs (domain, branch, pgAdmin4, etc.) as defaults. Only sets vars that are not already set (env overrides).
|
||
load_saved_deploy_vars() {
|
||
local file="${APP_ROOT}/.deploy_saved_vars"
|
||
[[ ! -f "${file}" ]] && return 0
|
||
log_info "Loading saved inputs from previous run (use env vars to override)..."
|
||
local line key val
|
||
while IFS= read -r line; do
|
||
[[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue
|
||
key="${BASH_REMATCH[1]}"
|
||
val="${BASH_REMATCH[2]}"
|
||
# Only set if not already set by environment
|
||
if [[ -z "${!key:-}" ]]; then
|
||
export "${key}=${val}"
|
||
fi
|
||
done < "${file}"
|
||
}
|
||
|
||
# Save current deploy inputs so next run can use them as defaults (resume after failure without re-entering).
|
||
save_deploy_saved_vars() {
|
||
mkdir -p "${APP_ROOT}"
|
||
local file="${APP_ROOT}/.deploy_saved_vars"
|
||
{
|
||
echo "API_DOMAIN=${API_DOMAIN:-}"
|
||
echo "UI_DOMAIN=${UI_DOMAIN:-}"
|
||
echo "BRANCH=${BRANCH:-main}"
|
||
echo "INSTALL_PGADMIN4=${INSTALL_PGADMIN4:-N}"
|
||
echo "PGADMIN4_DOMAIN=${PGADMIN4_DOMAIN:-}"
|
||
echo "PGADMIN4_EMAIL=${PGADMIN4_EMAIL:-}"
|
||
echo "PGADMIN4_PASSWORD=${PGADMIN4_PASSWORD:-}"
|
||
echo "UBUNTU_APT_MIRROR=${UBUNTU_APT_MIRROR:-}"
|
||
echo "INSTALL_VOICE=${INSTALL_VOICE:-N}"
|
||
echo "PIP_MIRROR=${PIP_MIRROR:-hesabix}"
|
||
echo "FLUTTER_MIRROR=${FLUTTER_MIRROR:-hesabix}"
|
||
echo "PIP_INDEX_URL=${PIP_INDEX_URL:-}"
|
||
echo "PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST:-}"
|
||
echo "PUB_HOSTED_URL=${PUB_HOSTED_URL:-}"
|
||
echo "FLUTTER_STORAGE_BASE_URL=${FLUTTER_STORAGE_BASE_URL:-}"
|
||
} > "${file}"
|
||
chmod 600 "${file}"
|
||
log_info "Saved inputs for next run (${file})"
|
||
}
|
||
|
||
# Persist UBUNTU_APT_MIRROR into .deploy_saved_vars (after apt mirror step).
|
||
persist_ubuntu_apt_mirror_to_saved_vars() {
|
||
local f="${APP_ROOT}/.deploy_saved_vars"
|
||
[[ ! -f "${f}" ]] && return 0
|
||
local tmp
|
||
tmp=$(mktemp)
|
||
grep -v '^UBUNTU_APT_MIRROR=' "${f}" > "${tmp}" 2>/dev/null || true
|
||
echo "UBUNTU_APT_MIRROR=${UBUNTU_APT_MIRROR:-keep}" >> "${tmp}"
|
||
mv "${tmp}" "${f}"
|
||
chmod 600 "${f}"
|
||
}
|
||
|
||
# Ubuntu only: optional switch to Arvan apt mirror before apt-get update (deb822 + classic deb lines).
|
||
configure_ubuntu_apt_mirror() {
|
||
local id=""
|
||
# shellcheck disable=SC1091
|
||
[[ -f /etc/os-release ]] && source /etc/os-release
|
||
[[ "${ID:-}" == "ubuntu" ]] || return 0
|
||
|
||
local codename="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"
|
||
log_info "Ubuntu apt mirror: detected codename ${codename:-unknown}"
|
||
|
||
UBUNTU_APT_MIRROR="${UBUNTU_APT_MIRROR:-}"
|
||
UBUNTU_APT_MIRROR="${UBUNTU_APT_MIRROR,,}"
|
||
if [[ -z "${UBUNTU_APT_MIRROR}" ]]; then
|
||
echo
|
||
echo "APT package sources (Ubuntu only):"
|
||
echo " [1] Keep current system configuration (default)"
|
||
echo " [2] Arvan Cloud mirror — mirror.arvancloud.ir (replaces archive.ubuntu.com and security.ubuntu.com)"
|
||
echo " [3] Restore official Ubuntu — only if Arvan was configured (archive + security)"
|
||
read -rp "Choose [1/2/3] (default 1): " _apt_mirror_choice
|
||
_apt_mirror_choice=${_apt_mirror_choice:-1}
|
||
case "${_apt_mirror_choice}" in
|
||
2) UBUNTU_APT_MIRROR=arvan ;;
|
||
3) UBUNTU_APT_MIRROR=official ;;
|
||
*) UBUNTU_APT_MIRROR=keep ;;
|
||
esac
|
||
fi
|
||
export UBUNTU_APT_MIRROR
|
||
|
||
if [[ "${UBUNTU_APT_MIRROR}" == "keep" ]]; then
|
||
log_info "APT sources: leaving unchanged (UBUNTU_APT_MIRROR=keep)."
|
||
persist_ubuntu_apt_mirror_to_saved_vars
|
||
return 0
|
||
fi
|
||
|
||
if [[ "${UBUNTU_APT_MIRROR}" != "arvan" && "${UBUNTU_APT_MIRROR}" != "official" ]]; then
|
||
log_warning "Invalid UBUNTU_APT_MIRROR='${UBUNTU_APT_MIRROR}', using keep."
|
||
UBUNTU_APT_MIRROR=keep
|
||
export UBUNTU_APT_MIRROR
|
||
persist_ubuntu_apt_mirror_to_saved_vars
|
||
return 0
|
||
fi
|
||
|
||
local backup_dir="/etc/apt/hesabix-apt-mirror-backup-$(date +%Y%m%d%H%M%S)"
|
||
mkdir -p "${backup_dir}"
|
||
|
||
local candidates=()
|
||
local f
|
||
[[ -f /etc/apt/sources.list ]] && candidates+=("/etc/apt/sources.list")
|
||
for f in /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do
|
||
[[ -f "${f}" ]] || continue
|
||
candidates+=("${f}")
|
||
done
|
||
|
||
local files_to_edit=()
|
||
for f in "${candidates[@]}"; do
|
||
if grep -qE 'archive\.ubuntu\.com/ubuntu|security\.ubuntu\.com/ubuntu|mirror\.arvancloud\.ir/ubuntu' "${f}" 2>/dev/null; then
|
||
files_to_edit+=("${f}")
|
||
local rel="${f#/}"
|
||
rel="${rel//\//__}"
|
||
cp -a "${f}" "${backup_dir}/${rel}.bak"
|
||
fi
|
||
done
|
||
|
||
if [[ ${#files_to_edit[@]} -eq 0 ]]; then
|
||
log_info "No Ubuntu archive/security/Arvan lines found in apt sources; nothing to change."
|
||
persist_ubuntu_apt_mirror_to_saved_vars
|
||
return 0
|
||
fi
|
||
|
||
log_info "APT mirror mode: ${UBUNTU_APT_MIRROR} (backup: ${backup_dir})"
|
||
|
||
local apt_file_list
|
||
apt_file_list=$(printf '%s\n' "${files_to_edit[@]}")
|
||
|
||
UBUNTU_APT_MIRROR_MODE="${UBUNTU_APT_MIRROR}" HESABIX_APT_FILE_LIST="${apt_file_list}" python3 <<'PY'
|
||
import os, re, sys
|
||
|
||
mode = os.environ.get("UBUNTU_APT_MIRROR_MODE", "")
|
||
paths = [p for p in os.environ.get("HESABIX_APT_FILE_LIST", "").splitlines() if p.strip()]
|
||
ARVAN = "http://mirror.arvancloud.ir/ubuntu"
|
||
ARCHIVE = "http://archive.ubuntu.com/ubuntu"
|
||
SECURITY = "http://security.ubuntu.com/ubuntu"
|
||
|
||
|
||
def apply_arvan(text: str) -> str:
|
||
if "archive.ubuntu.com/ubuntu" not in text and "security.ubuntu.com/ubuntu" not in text:
|
||
return text
|
||
t = re.sub(r"https?://archive\.ubuntu\.com/ubuntu\b", ARVAN, text)
|
||
t = re.sub(r"https?://security\.ubuntu\.com/ubuntu\b", ARVAN, t)
|
||
return t
|
||
|
||
|
||
def apply_official(text: str) -> str:
|
||
if "mirror.arvancloud.ir" not in text:
|
||
return text
|
||
if re.search(r"(?m)^Types:\s*deb", text) and re.search(r"(?m)^URIs:", text):
|
||
blocks = re.split(r"\n{2,}", text)
|
||
out = []
|
||
for b in blocks:
|
||
if not b.strip():
|
||
continue
|
||
if re.search(r"(?m)^URIs:\s*https?://mirror\.arvancloud\.ir/ubuntu\b", b):
|
||
sm = re.search(r"(?m)^Suites:(.*)$", b)
|
||
suites = sm.group(1) if sm else ""
|
||
uri = SECURITY if re.search(r"\b[\w.-]+-security\b", suites) else ARCHIVE
|
||
b = re.sub(r"(?m)^URIs:\s*[^\n]+", "URIs: " + uri, b, count=1)
|
||
out.append(b)
|
||
joined = "\n\n".join(out)
|
||
if text.endswith("\n") and not joined.endswith("\n"):
|
||
joined += "\n"
|
||
return joined
|
||
lines = []
|
||
for line in text.splitlines(True):
|
||
if re.match(r"^\s*deb(-src)?\s+", line) and "mirror.arvancloud.ir" in line:
|
||
parts = line.split()
|
||
if len(parts) >= 3 and parts[0] in ("deb", "deb-src"):
|
||
suite = parts[2]
|
||
parts[1] = SECURITY if "-security" in suite else ARCHIVE
|
||
line = " ".join(parts) + ("\n" if line.endswith("\n") else "")
|
||
lines.append(line)
|
||
return "".join(lines)
|
||
|
||
|
||
for path in paths:
|
||
try:
|
||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||
content = fh.read()
|
||
except OSError as e:
|
||
print(f"skip read {path}: {e}", file=sys.stderr)
|
||
continue
|
||
if mode == "arvan":
|
||
new = apply_arvan(content)
|
||
elif mode == "official":
|
||
new = apply_official(content)
|
||
else:
|
||
new = content
|
||
if new != content:
|
||
with open(path, "w", encoding="utf-8") as fh:
|
||
fh.write(new)
|
||
print(path)
|
||
PY
|
||
|
||
if ! apt-get update -y; then
|
||
log_error "apt-get update failed after mirror change; restoring from ${backup_dir}"
|
||
for f in "${files_to_edit[@]}"; do
|
||
local rel="${f#/}"
|
||
rel="${rel//\//__}"
|
||
if [[ -f "${backup_dir}/${rel}.bak" ]]; then
|
||
cp -a "${backup_dir}/${rel}.bak" "${f}"
|
||
fi
|
||
done
|
||
UBUNTU_APT_MIRROR=keep
|
||
export UBUNTU_APT_MIRROR
|
||
persist_ubuntu_apt_mirror_to_saved_vars
|
||
if apt-get update -y; then
|
||
log_warning "Restored previous apt sources; continuing with prerequisite install."
|
||
return 0
|
||
fi
|
||
return 1
|
||
fi
|
||
|
||
log_success "APT sources updated (${UBUNTU_APT_MIRROR}); apt-get update succeeded."
|
||
persist_ubuntu_apt_mirror_to_saved_vars
|
||
return 0
|
||
}
|
||
|
||
# Save deployment config for hesabix CLI (-update, -services, …) and install /usr/local/bin/hesabix
|
||
install_hesabix_command() {
|
||
mkdir -p "${APP_ROOT}"
|
||
local env_file="${APP_ROOT}/.deploy_env"
|
||
if declare -F hesabix_apply_pip_mirror_env >/dev/null 2>&1; then
|
||
hesabix_apply_pip_mirror_env
|
||
hesabix_apply_flutter_mirror_env
|
||
fi
|
||
cat > "${env_file}" <<ENV
|
||
API_DOMAIN=${API_DOMAIN}
|
||
UI_DOMAIN=${UI_DOMAIN}
|
||
BRANCH=${BRANCH}
|
||
REPO_URL=${REPO_URL}
|
||
INSTALL_VOICE=${INSTALL_VOICE:-N}
|
||
PIP_MIRROR=${PIP_MIRROR:-hesabix}
|
||
FLUTTER_MIRROR=${FLUTTER_MIRROR:-hesabix}
|
||
PIP_INDEX_URL=${PIP_INDEX_URL:-}
|
||
PIP_TRUSTED_HOST=${PIP_TRUSTED_HOST:-}
|
||
PUB_HOSTED_URL=${PUB_HOSTED_URL:-}
|
||
FLUTTER_STORAGE_BASE_URL=${FLUTTER_STORAGE_BASE_URL:-}
|
||
ENV
|
||
chmod 600 "${env_file}"
|
||
log_info "Saved deployment config to ${env_file}"
|
||
|
||
local bin_hesabix="/usr/local/bin/hesabix"
|
||
local cli_src="${APP_ROOT}/app/scripts/hesabix"
|
||
if [[ ! -f "${cli_src}" ]]; then
|
||
cli_src="$(pwd)/scripts/hesabix"
|
||
fi
|
||
if [[ ! -f "${cli_src}" ]]; then
|
||
log_error "CLI source not found: scripts/hesabix (expected at ${APP_ROOT}/app/scripts/hesabix)"
|
||
return 1
|
||
fi
|
||
if command -v install >/dev/null 2>&1; then
|
||
install -m 755 "${cli_src}" "${bin_hesabix}"
|
||
else
|
||
cp -f "${cli_src}" "${bin_hesabix}"
|
||
chmod 755 "${bin_hesabix}" 2>/dev/null || true
|
||
fi
|
||
log_success "Command installed: hesabix (e.g. sudo hesabix -update | sudo hesabix -domains show | sudo hesabix -ssl status | sudo hesabix -cli reload)"
|
||
}
|
||
|
||
reset_deployment_state() {
|
||
: "${RESET_STATE:=}"
|
||
if [[ -z "${RESET_STATE}" ]] && [[ -f "${STATE_FILE}" ]]; then
|
||
echo
|
||
read -rp "Previous deployment state found. Reset and start from beginning? (y/N): " RESET_STATE
|
||
RESET_STATE=${RESET_STATE:-N}
|
||
fi
|
||
|
||
if [[ "${RESET_STATE}" =~ ^[Yy]$ ]]; then
|
||
clear_deployment_state
|
||
echo "$CHECK_MARK Deployment state reset. Starting from beginning."
|
||
fi
|
||
}
|
||
|
||
show_license_info() {
|
||
cat <<LICENSE
|
||
|
||
╔═══════════════════════════════════════════════════════════════════════╗
|
||
║ Hesabix - Comprehensive Accounting System ║
|
||
║ Open Source Software under GPL v3 ║
|
||
╚═══════════════════════════════════════════════════════════════════════╝
|
||
|
||
📋 About the Software:
|
||
Hesabix is a complete and modern accounting system that includes:
|
||
• Powerful API (FastAPI + PostgreSQL)
|
||
• Beautiful User Interface (Flutter Web)
|
||
• Open source and free
|
||
|
||
👨💻 Developers:
|
||
Hesabix Team
|
||
Website: https://hesabix.ir
|
||
Support: https://hesabix.ir/support
|
||
|
||
📦 Project Repository:
|
||
https://source.hesabix.ir/hesabix/arc.git
|
||
|
||
📄 License:
|
||
This software is distributed under the GNU General Public License v3.0 (GPL-3.0).
|
||
|
||
Full license text: http://www.gnu.org/licenses/gpl-3.0.txt
|
||
|
||
Summary of Rights:
|
||
✓ You are free to run the software
|
||
✓ You are free to study and modify the software
|
||
✓ You are free to distribute the software
|
||
✓ You are free to distribute improved versions
|
||
|
||
Condition: Any distribution or modified version must be under the same GPL v3
|
||
license and source code must be made available.
|
||
|
||
⚠️ This software is provided WITHOUT ANY WARRANTY.
|
||
|
||
╔═══════════════════════════════════════════════════════════════════════╗
|
||
║ GNU GENERAL PUBLIC LICENSE ║
|
||
║ Version 3, 29 June 2007 ║
|
||
║ ║
|
||
║ Copyright (C) 2024 Hesabix Team <https://hesabix.ir> ║
|
||
║ ║
|
||
║ This program is free software: you can redistribute it and/or ║
|
||
║ modify it under the terms of the GNU General Public License as ║
|
||
║ published by the Free Software Foundation, either version 3 of the ║
|
||
║ License, or (at your option) any later version. ║
|
||
║ ║
|
||
║ This program is distributed in the hope that it will be useful, ║
|
||
║ but WITHOUT ANY WARRANTY; without even the implied warranty of ║
|
||
║ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU ║
|
||
║ General Public License for more details. ║
|
||
║ ║
|
||
║ You should have received a copy of the GNU General Public License ║
|
||
║ along with this program. If not, see ║
|
||
║ <http://www.gnu.org/licenses/>. ║
|
||
╚═══════════════════════════════════════════════════════════════════════╝
|
||
|
||
LICENSE
|
||
}
|
||
|
||
accept_license() {
|
||
: "${ACCEPT_LICENSE:=}"
|
||
|
||
if [[ -z "${ACCEPT_LICENSE}" ]]; then
|
||
echo
|
||
echo "⚠️ To continue installation, you must agree to the GNU GPL v3 license terms."
|
||
echo
|
||
read -rp "Do you agree to the terms of the GNU General Public License v3.0? (yes/no): " ACCEPT_LICENSE
|
||
fi
|
||
|
||
case "${ACCEPT_LICENSE}" in
|
||
[Yy][Ee][Ss]|y|Y)
|
||
echo "$CHECK_MARK License agreement accepted (GNU GPL v3.0)."
|
||
return 0
|
||
;;
|
||
*)
|
||
echo "$CROSS_MARK You must agree to the license terms to continue installation."
|
||
echo
|
||
echo "To view the full license text, visit:"
|
||
echo " http://www.gnu.org/licenses/gpl-3.0.txt"
|
||
echo
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
prompt_vars() {
|
||
: "${API_DOMAIN:=}"
|
||
: "${UI_DOMAIN:=}"
|
||
: "${BRANCH:=main}"
|
||
: "${DB_PASSWORD:=}"
|
||
# Display in summary; default internal tarball FLUTTER_SDK_TARBALL_URL_INTERNAL ≈ 3.41.x stable
|
||
: "${FLUTTER_VERSION:=3.41.1}"
|
||
: "${UVICORN_WORKERS:=}"
|
||
|
||
# Calculate optimal worker count based on CPU cores if not provided
|
||
if [[ -z "${UVICORN_WORKERS}" ]]; then
|
||
UVICORN_WORKERS=$(calculate_optimal_workers)
|
||
log_info "Auto-calculated optimal worker count: ${UVICORN_WORKERS} (based on CPU cores)"
|
||
fi
|
||
|
||
# Calculate optimal database pool settings based on worker count
|
||
local pool_settings
|
||
pool_settings=$(calculate_db_pool_settings "${UVICORN_WORKERS}")
|
||
DB_POOL_SIZE=$(echo "${pool_settings}" | cut -d: -f1)
|
||
DB_MAX_OVERFLOW=$(echo "${pool_settings}" | cut -d: -f2)
|
||
|
||
log_info "Auto-calculated database pool settings: pool_size=${DB_POOL_SIZE}, max_overflow=${DB_MAX_OVERFLOW}"
|
||
tune_deployment_for_system_ram
|
||
|
||
if [[ -z "${API_DOMAIN}" ]]; then
|
||
read -rp "API domain (e.g., api.example.com): " API_DOMAIN
|
||
fi
|
||
if ! validate_domain "${API_DOMAIN}"; then
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -z "${UI_DOMAIN}" ]]; then
|
||
read -rp "Frontend domain (e.g., app.example.com): " UI_DOMAIN
|
||
fi
|
||
if ! validate_domain "${UI_DOMAIN}"; then
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -z "${BRANCH}" ]]; then
|
||
read -rp "Branch name (default: main): " BRANCH
|
||
BRANCH=${BRANCH:-main}
|
||
fi
|
||
|
||
# Generate or prompt for DB password
|
||
if [[ -z "${DB_PASSWORD}" ]]; then
|
||
if [[ -f "${APP_ROOT}/.db_password" ]]; then
|
||
DB_PASSWORD=$(cat "${APP_ROOT}/.db_password")
|
||
echo "$CHECK_MARK Using existing password from previous run"
|
||
else
|
||
read -rsp "Database password (empty for auto-generate): " DB_PASSWORD
|
||
echo
|
||
if [[ -z "${DB_PASSWORD}" ]]; then
|
||
DB_PASSWORD=$(generate_password)
|
||
echo "$CHECK_MARK Password auto-generated"
|
||
fi
|
||
# Save password for future runs
|
||
mkdir -p "${APP_ROOT}"
|
||
echo -n "${DB_PASSWORD}" > "${APP_ROOT}/.db_password"
|
||
chmod 600 "${APP_ROOT}/.db_password"
|
||
fi
|
||
else
|
||
# Save provided password
|
||
mkdir -p "${APP_ROOT}"
|
||
echo -n "${DB_PASSWORD}" > "${APP_ROOT}/.db_password"
|
||
chmod 600 "${APP_ROOT}/.db_password"
|
||
fi
|
||
|
||
# Prompt for pgAdmin4 installation (optional)
|
||
: "${INSTALL_PGADMIN4:=}"
|
||
: "${PGADMIN4_DOMAIN:=}"
|
||
: "${PGADMIN4_EMAIL:=}"
|
||
: "${PGADMIN4_PASSWORD:=}"
|
||
|
||
if [[ -z "${INSTALL_PGADMIN4}" ]]; then
|
||
echo
|
||
read -rp "Install pgAdmin4 (PostgreSQL web admin)? (y/N): " INSTALL_PGADMIN4
|
||
INSTALL_PGADMIN4=${INSTALL_PGADMIN4:-N}
|
||
fi
|
||
|
||
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]]; then
|
||
if [[ -z "${PGADMIN4_DOMAIN}" ]]; then
|
||
read -rp "pgAdmin4 domain (e.g., pgadmin.example.com): " PGADMIN4_DOMAIN
|
||
fi
|
||
if ! validate_domain "${PGADMIN4_DOMAIN}"; then
|
||
echo "$WARNING_MARK Invalid pgAdmin4 domain. Skipping pgAdmin4 installation."
|
||
INSTALL_PGADMIN4="N"
|
||
else
|
||
if [[ -z "${PGADMIN4_EMAIL}" ]]; then
|
||
read -rp "pgAdmin4 admin email: " PGADMIN4_EMAIL
|
||
fi
|
||
if [[ -z "${PGADMIN4_PASSWORD}" ]]; then
|
||
read -rsp "pgAdmin4 admin password: " PGADMIN4_PASSWORD
|
||
echo
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# AI voice chat — optional (local STT/TTS)
|
||
: "${INSTALL_VOICE:=}"
|
||
if [[ -z "${INSTALL_VOICE}" ]]; then
|
||
echo
|
||
echo "AI voice chat: on-server speech recognition and TTS (no cloud API; needs more RAM/CPU)."
|
||
read -rp "Install AI voice chat dependencies (pip [voice], ~2–4GB disk)? (y/N): " INSTALL_VOICE
|
||
INSTALL_VOICE=${INSTALL_VOICE:-N}
|
||
fi
|
||
|
||
if declare -F hesabix_prompt_pip_mirror >/dev/null 2>&1; then
|
||
hesabix_prompt_pip_mirror
|
||
hesabix_prompt_flutter_mirror
|
||
hesabix_apply_pip_mirror_env
|
||
hesabix_apply_flutter_mirror_env
|
||
fi
|
||
|
||
save_deploy_saved_vars
|
||
export API_DOMAIN UI_DOMAIN BRANCH DB_PASSWORD UVICORN_WORKERS FLUTTER_VERSION INSTALL_PGADMIN4 PGADMIN4_DOMAIN PGADMIN4_EMAIL PGADMIN4_PASSWORD INSTALL_VOICE DB_POOL_SIZE DB_MAX_OVERFLOW PIP_MIRROR FLUTTER_MIRROR PIP_INDEX_URL PIP_TRUSTED_HOST PUB_HOSTED_URL FLUTTER_STORAGE_BASE_URL
|
||
}
|
||
|
||
# Show configuration summary and ask for confirmation
|
||
show_config_summary() {
|
||
echo
|
||
echo "=========================================="
|
||
echo " Configuration Summary"
|
||
echo "=========================================="
|
||
echo
|
||
echo "Domains:"
|
||
echo " • API Domain: ${API_DOMAIN}"
|
||
echo " • UI Domain: ${UI_DOMAIN}"
|
||
echo
|
||
echo "Repository Settings:"
|
||
echo " • Branch: ${BRANCH}"
|
||
echo " • Repository: ${REPO_URL}"
|
||
echo
|
||
echo "Database Settings:"
|
||
echo " • Database Name: hesabix"
|
||
echo " • Database User: hesabix"
|
||
echo " • Database Host: 127.0.0.1:5432"
|
||
if [[ -f "${APP_ROOT}/.db_password" ]]; then
|
||
echo " • Password: (Using existing password)"
|
||
else
|
||
echo " • Password: (Auto-generated)"
|
||
fi
|
||
echo
|
||
echo "Server Settings:"
|
||
echo " • Uvicorn Workers: ${UVICORN_WORKERS} (auto-calculated based on CPU cores)"
|
||
echo " • Flutter Version: ${FLUTTER_VERSION}"
|
||
echo " • DB Pool Size: ${DB_POOL_SIZE} (auto-calculated)"
|
||
echo " • DB Max Overflow: ${DB_MAX_OVERFLOW} (auto-calculated)"
|
||
echo
|
||
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]]; then
|
||
echo "pgAdmin4:"
|
||
echo " • Domain: ${PGADMIN4_DOMAIN}"
|
||
echo " • Admin Email: ${PGADMIN4_EMAIL}"
|
||
echo
|
||
else
|
||
echo "pgAdmin4: Not installed"
|
||
echo
|
||
fi
|
||
if [[ "${INSTALL_VOICE}" =~ ^[Yy]$ ]]; then
|
||
echo "AI Voice Chat: Enabled (local Whisper + Coqui TTS)"
|
||
echo " • Data dir: /var/lib/hesabix/voice-data"
|
||
else
|
||
echo "AI Voice Chat: Not installed (optional)"
|
||
echo
|
||
fi
|
||
echo "Package mirrors:"
|
||
if declare -F hesabix_mirror_summary_pip >/dev/null 2>&1; then
|
||
hesabix_mirror_summary_pip
|
||
hesabix_mirror_summary_flutter
|
||
else
|
||
echo " • PyPI (pip): hesabix (default)"
|
||
echo " • Flutter: hesabix (default)"
|
||
fi
|
||
echo
|
||
echo "Installation Paths:"
|
||
echo " • Application: ${APP_ROOT}/app"
|
||
echo " • Frontend: /var/www/${UI_DOMAIN}"
|
||
echo " • Log File: ${LOG_FILE}"
|
||
echo
|
||
echo "=========================================="
|
||
echo
|
||
}
|
||
|
||
# Ask user for final confirmation before starting installation
|
||
confirm_installation() {
|
||
: "${CONFIRM_INSTALL:=}"
|
||
|
||
if [[ -z "${CONFIRM_INSTALL}" ]]; then
|
||
echo "⚠️ Do you want to start installation with these settings?"
|
||
echo
|
||
read -rp "Confirm installation (yes/no): " CONFIRM_INSTALL
|
||
fi
|
||
|
||
case "${CONFIRM_INSTALL}" in
|
||
[Yy][Ee][Ss]|yes|y|Y)
|
||
log_info "User confirmed installation. Starting deployment..."
|
||
return 0
|
||
;;
|
||
*)
|
||
log_info "Installation cancelled by user."
|
||
echo
|
||
echo "$CROSS_MARK Installation cancelled."
|
||
echo "To start again, please run the script again."
|
||
echo
|
||
exit 0
|
||
;;
|
||
esac
|
||
}
|
||
|
||
install_prereqs() {
|
||
log_step "Installing prerequisites..."
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
|
||
configure_ubuntu_apt_mirror
|
||
|
||
# Update package list
|
||
log_info "Updating package list..."
|
||
apt-get update -y
|
||
|
||
# hesabix-api requires Python >= 3.11 (pyproject.toml). Ubuntu 24.04: python3=3.12;
|
||
# Ubuntu 22.04: python3=3.10 — install python3.11 when needed (scripts/hesabix_python.sh).
|
||
# WeasyPrint (PDF) requires: libcairo2, libpango*, libgdk-pixbuf-2.0-0 (note: hyphen in package name on Ubuntu 24)
|
||
log_info "Installing: git, curl, unzip, xz-utils, ca-certificates, python3, python3-venv, python3-pip, build-essential, nginx, postgresql, postgresql-contrib, postgresql-client, redis-server, WeasyPrint system deps (libpango/cairo)..."
|
||
apt-get install -y git curl unzip xz-utils ca-certificates \
|
||
python3 python3-venv python3-pip build-essential \
|
||
nginx postgresql postgresql-contrib postgresql-client redis-server \
|
||
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info
|
||
|
||
if ! hesabix_ensure_backend_python; then
|
||
log_error "hesabix-api requires Python >= 3.11. Install python3.11 (or newer) and re-run deploy."
|
||
exit 1
|
||
fi
|
||
local backend_python backend_py_ver
|
||
backend_python=$(hesabix_resolve_backend_python)
|
||
backend_py_ver=$("${backend_python}" --version 2>&1)
|
||
log_info "Backend Python: ${backend_py_ver} (${backend_python})"
|
||
if command -v python3 >/dev/null 2>&1; then
|
||
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
|
||
log_info "System default python3: ${PYTHON_VERSION}"
|
||
fi
|
||
|
||
# Ensure PostgreSQL service is enabled and started
|
||
if command -v systemctl >/dev/null 2>&1; then
|
||
# Check if postgresql service exists (may be postgresql or postgresql@*)
|
||
if systemctl list-unit-files 2>/dev/null | grep -qE "postgresql(@|\.service)"; then
|
||
log_info "Enabling and starting PostgreSQL service..."
|
||
systemctl enable postgresql 2>/dev/null || true
|
||
systemctl start postgresql 2>/dev/null || true
|
||
|
||
# Also try specific version service (e.g., postgresql@16-main)
|
||
local pg_service
|
||
pg_service=$(systemctl list-units --type=service --state=inactive,active 2>/dev/null | grep -oE "postgresql@[0-9]+-main" | head -1 || echo "")
|
||
if [[ -n "${pg_service}" ]]; then
|
||
log_info "Starting PostgreSQL service: ${pg_service}"
|
||
systemctl enable "${pg_service}" 2>/dev/null || true
|
||
systemctl start "${pg_service}" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
|
||
# Ensure Redis service is enabled and started
|
||
if systemctl list-unit-files 2>/dev/null | grep -qE "redis(@|\.service|server)"; then
|
||
log_info "Enabling and starting Redis service..."
|
||
systemctl enable redis-server 2>/dev/null || systemctl enable redis 2>/dev/null || true
|
||
systemctl start redis-server 2>/dev/null || systemctl start redis 2>/dev/null || true
|
||
fi
|
||
fi
|
||
|
||
# pgvector for semantic RAG search (optional; non-fatal if apt package missing)
|
||
if [[ -f "${DEPLOY_SCRIPT_DIR}/scripts/ensure_pgvector.sh" ]]; then
|
||
chmod +x "${DEPLOY_SCRIPT_DIR}/scripts/ensure_pgvector.sh" 2>/dev/null || true
|
||
log_info "Ensuring PostgreSQL pgvector package (optional)..."
|
||
if bash "${DEPLOY_SCRIPT_DIR}/scripts/ensure_pgvector.sh"; then
|
||
log_success "pgvector package check completed."
|
||
else
|
||
log_warning "pgvector package install skipped or failed (non-fatal)."
|
||
fi
|
||
fi
|
||
|
||
log_success "Prerequisites installed (or already present)."
|
||
}
|
||
|
||
clone_repo() {
|
||
log_step "Cloning/updating repository..."
|
||
mkdir -p "${APP_ROOT}"
|
||
cd "${APP_ROOT}"
|
||
|
||
# If app exists but .git is missing (e.g. previous clone failed halfway), remove for fresh clone
|
||
if [[ -d "${APP_ROOT}/app" ]] && [[ ! -d "${APP_ROOT}/app/.git" ]]; then
|
||
log_info "Removing incomplete app directory for fresh clone..."
|
||
rm -rf "${APP_ROOT}/app"
|
||
fi
|
||
|
||
if [[ ! -d "${APP_ROOT}/app/.git" ]]; then
|
||
log_info "Cloning repository..."
|
||
|
||
# Try to clone with specified branch first
|
||
if git clone -b "${BRANCH}" "${REPO_URL}" app 2>/dev/null; then
|
||
cd app
|
||
log_success "Repository cloned successfully on branch: ${BRANCH}"
|
||
else
|
||
# If branch doesn't exist, clone without branch and use default
|
||
log_warning "Branch '${BRANCH}' not found. Cloning default branch..."
|
||
if ! git clone "${REPO_URL}" app; then
|
||
log_error "Error cloning repository"
|
||
exit 1
|
||
fi
|
||
cd app
|
||
|
||
# Detect default branch (git clone automatically checks out default branch)
|
||
local default_branch
|
||
default_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||
if [[ -n "${default_branch}" ]]; then
|
||
BRANCH="${default_branch}"
|
||
log_info "Using default branch: ${default_branch}"
|
||
else
|
||
log_warning "Could not detect default branch. Using current HEAD."
|
||
fi
|
||
fi
|
||
else
|
||
log_info "Updating existing repository..."
|
||
cd app
|
||
|
||
# Save current branch
|
||
local current_branch
|
||
current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||
|
||
# Fetch all branches
|
||
if ! git fetch --all --prune; then
|
||
log_warning "Error fetching. Continuing with current state..."
|
||
fi
|
||
|
||
# Checkout target branch
|
||
if ! git checkout "${BRANCH}" 2>/dev/null; then
|
||
log_warning "Branch ${BRANCH} not found. Using current branch: ${current_branch}"
|
||
BRANCH="${current_branch}"
|
||
fi
|
||
|
||
# Try to pull, but don't fail if it's not a fast-forward
|
||
if ! git pull --ff-only 2>/dev/null; then
|
||
log_warning "Pull failed (may need merge). Using current state..."
|
||
git reset --hard "origin/${BRANCH}" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
|
||
# Verify we're on the right branch
|
||
local actual_branch
|
||
actual_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||
log_success "Repository ready at ${APP_ROOT}/app (branch: ${actual_branch})"
|
||
}
|
||
|
||
setup_db() {
|
||
log_step "Configuring database (PostgreSQL)..."
|
||
|
||
# If conf.d was written with unrealistic shared_buffers, PostgreSQL won't start and
|
||
# wait_for_db never succeeds; detect version from disk first, write conf, then restart.
|
||
local pg_opt_applied_early=false
|
||
local pg_ver_early=""
|
||
pg_ver_early=$(detect_postgresql_major_version_for_config 2>/dev/null || true)
|
||
if [[ -n "${pg_ver_early}" ]] && [[ "${pg_ver_early}" =~ ^[0-9]+$ ]]; then
|
||
log_info "Applying preventive PostgreSQL settings (version ${pg_ver_early}) so the service starts with valid conf..."
|
||
write_postgresql_hesabix_optimization_conf "${pg_ver_early}"
|
||
restart_postgresql_after_config_change "${pg_ver_early}" ""
|
||
pg_opt_applied_early=true
|
||
fi
|
||
|
||
# Start and enable PostgreSQL service
|
||
# PostgreSQL service may be named 'postgresql' or 'postgresql@VERSION-main'
|
||
local pg_service_found=false
|
||
local pg_service=""
|
||
local unit_files=""
|
||
unit_files=$(systemctl list-unit-files --no-pager --no-legend 2>/dev/null || true)
|
||
# list-units hides inactive units; list-unit-files lists all installed units
|
||
if echo "${unit_files}" | grep -qE '^postgresql@[0-9]+-main\.service[[:space:]]'; then
|
||
pg_service=$(echo "${unit_files}" | awk '/^postgresql@[0-9]+-main\.service[[:space:]]/ { sub(/\.service$/, "", $1); print $1; exit }')
|
||
pg_service_found=true
|
||
elif echo "${unit_files}" | grep -qE '^postgresql\.service[[:space:]]'; then
|
||
pg_service="postgresql"
|
||
pg_service_found=true
|
||
fi
|
||
|
||
if [[ "${pg_service_found}" == "true" ]]; then
|
||
log_info "Starting PostgreSQL service: ${pg_service:-postgresql}..."
|
||
systemctl enable "${pg_service:-postgresql}" 2>/dev/null || true
|
||
systemctl start "${pg_service:-postgresql}" 2>/dev/null || true
|
||
|
||
# Wait a moment for service to start
|
||
sleep 2
|
||
else
|
||
# Check if PostgreSQL is actually installed
|
||
if ! command -v psql >/dev/null 2>&1 && ! command -v postgres >/dev/null 2>&1; then
|
||
log_error "PostgreSQL is not installed. Please ensure prerequisites installation completed successfully."
|
||
exit 1
|
||
fi
|
||
|
||
log_warning "PostgreSQL systemd unit not found in list-unit-files. Trying meta service and pg_ctlcluster..."
|
||
systemctl daemon-reload 2>/dev/null || true
|
||
systemctl enable postgresql 2>/dev/null || true
|
||
systemctl start postgresql 2>/dev/null || true
|
||
|
||
if command -v pg_ctlcluster >/dev/null 2>&1; then
|
||
local pg_clust_ver=""
|
||
pg_clust_ver="${pg_ver_early:-}"
|
||
if [[ -z "${pg_clust_ver}" ]]; then
|
||
pg_clust_ver=$(pg_lsclusters 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1; exit}')
|
||
fi
|
||
if [[ -n "${pg_clust_ver}" ]]; then
|
||
log_info "Starting PostgreSQL cluster ${pg_clust_ver}/main via pg_ctlcluster..."
|
||
local pg_start_out=""
|
||
pg_start_out=$(pg_ctlcluster "${pg_clust_ver}" main start 2>&1) || true
|
||
[[ -n "${pg_start_out}" ]] && log_info "${pg_start_out}"
|
||
fi
|
||
fi
|
||
|
||
sleep 2
|
||
fi
|
||
|
||
# If we know the version but the cluster is still down, try explicit enable/start once more
|
||
if [[ -n "${pg_ver_early}" ]] && [[ "${pg_ver_early}" =~ ^[0-9]+$ ]]; then
|
||
systemctl enable "postgresql@${pg_ver_early}-main" 2>/dev/null || true
|
||
systemctl start "postgresql@${pg_ver_early}-main" 2>/dev/null || true
|
||
if command -v pg_ctlcluster >/dev/null 2>&1; then
|
||
if command -v pg_isready >/dev/null 2>&1 && ! pg_isready -q 2>/dev/null; then
|
||
local pg_boot_out=""
|
||
pg_boot_out=$(pg_ctlcluster "${pg_ver_early}" main start 2>&1) || true
|
||
[[ -n "${pg_boot_out}" ]] && log_info "${pg_boot_out}"
|
||
fi
|
||
fi
|
||
sleep 1
|
||
fi
|
||
|
||
# Wait for database to be ready
|
||
if ! wait_for_db; then
|
||
log_error "Database not ready. Please check PostgreSQL installation and service status."
|
||
log_error "You can check PostgreSQL status with: systemctl status postgresql"
|
||
exit 1
|
||
fi
|
||
|
||
# Configure PostgreSQL to allow local connections (if needed)
|
||
local pg_version
|
||
local ver_num
|
||
ver_num=$(sudo -u postgres psql -tAc "SHOW server_version_num;" 2>/dev/null | tr -d '[:space:]')
|
||
if [[ -n "${ver_num}" ]] && [[ "${ver_num}" =~ ^[0-9]+$ ]]; then
|
||
pg_version=$((10#${ver_num} / 10000))
|
||
else
|
||
pg_version=$(sudo -u postgres psql -tAc "SHOW server_version;" 2>/dev/null | cut -d. -f1 | tr -d '[:space:]')
|
||
fi
|
||
if [[ -z "${pg_version}" ]] || [[ ! "${pg_version}" =~ ^[0-9]+$ ]]; then
|
||
log_error "Could not detect PostgreSQL major version for config paths."
|
||
exit 1
|
||
fi
|
||
local pg_hba="/etc/postgresql/${pg_version}/main/pg_hba.conf"
|
||
|
||
# Allow password authentication for localhost connections
|
||
# Use scram-sha-256 (more secure than md5) if PostgreSQL version supports it
|
||
# For PostgreSQL 10+, scram-sha-256 is the default and recommended method
|
||
if [[ -f "${pg_hba}" ]] && ! grep -q "host.*hesabix.*127.0.0.1/32" "${pg_hba}"; then
|
||
# Try scram-sha-256 first (PostgreSQL 10+), fallback to md5 for older versions
|
||
if [[ "${pg_version}" -ge 10 ]]; then
|
||
echo "host hesabix hesabix 127.0.0.1/32 scram-sha-256" >> "${pg_hba}"
|
||
else
|
||
echo "host hesabix hesabix 127.0.0.1/32 md5" >> "${pg_hba}"
|
||
fi
|
||
systemctl reload postgresql || true
|
||
fi
|
||
|
||
# Create database and user (password may contain quotes/special characters)
|
||
if ! apply_postgres_hesabix_password; then
|
||
log_error "Failed to create/update PostgreSQL user hesabix (password apply failed)."
|
||
exit 1
|
||
fi
|
||
|
||
# Create database separately to avoid issues with conditional creation
|
||
if ! sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = 'hesabix'" | grep -q 1; then
|
||
sudo -u postgres createdb -O hesabix -E UTF8 -T template0 hesabix
|
||
fi
|
||
|
||
# Grant privileges
|
||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE hesabix TO hesabix;"
|
||
# hesabix must own public schema so pg_restore/CREATE TABLE works (Ubuntu default owner is postgres).
|
||
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL'
|
||
ALTER SCHEMA public OWNER TO hesabix;
|
||
GRANT ALL ON SCHEMA public TO hesabix;
|
||
GRANT CREATE ON SCHEMA public TO hesabix;
|
||
SQL
|
||
|
||
# Verify connection
|
||
if PGPASSWORD="${DB_PASSWORD}" psql -U hesabix -h 127.0.0.1 -d hesabix -c "SELECT 1" >/dev/null 2>&1; then
|
||
log_success "Database and user created, connection verified."
|
||
else
|
||
log_warning "Connection test failed, but database may still be accessible. Continuing..."
|
||
fi
|
||
|
||
if [[ "${pg_opt_applied_early}" != "true" ]]; then
|
||
log_info "Applying PostgreSQL settings after service is up (version ${pg_version})..."
|
||
write_postgresql_hesabix_optimization_conf "${pg_version}"
|
||
restart_postgresql_after_config_change "${pg_version}" "${pg_service:-}"
|
||
fi
|
||
}
|
||
|
||
hesabix_db_table_exists() {
|
||
local table="$1"
|
||
# Use postgres superuser: pg_restore --no-owner leaves objects owned by postgres;
|
||
# hesabix cannot see them in information_schema until grants/ownership are fixed.
|
||
sudo -u postgres psql -d hesabix -tAc \
|
||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='${table}');" \
|
||
2>/dev/null | tr -d '[:space:]'
|
||
}
|
||
|
||
hesabix_current_alembic_revision() {
|
||
if [[ "$(hesabix_db_table_exists alembic_version)" != "t" ]]; then
|
||
return 0
|
||
fi
|
||
PGPASSWORD="${DB_PASSWORD}" psql -h 127.0.0.1 -p 5432 -U hesabix -d hesabix -tAc \
|
||
"SELECT version_num FROM alembic_version LIMIT 1;" 2>/dev/null | tr -d '[:space:]'
|
||
}
|
||
|
||
hesabix_validate_seed_schema() {
|
||
local t missing=()
|
||
for t in documents document_lines businesses users accounts; do
|
||
if [[ "$(hesabix_db_table_exists "${t}")" != "t" ]]; then
|
||
missing+=("${t}")
|
||
fi
|
||
done
|
||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||
log_error "Seed import incomplete. Missing core tables: ${missing[*]}"
|
||
log_error "See ${APP_ROOT}/pg_restore_seed.log — drop/recreate DB and re-run deploy, or fix the seed dump."
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
# True when seed/base schema is present (not just alembic_version from a failed run).
|
||
hesabix_db_is_fully_initialized() {
|
||
[[ "$(hesabix_db_table_exists businesses)" == "t" ]] \
|
||
&& [[ "$(hesabix_db_table_exists document_lines)" == "t" ]] \
|
||
&& [[ "$(hesabix_db_table_exists documents)" == "t" ]]
|
||
}
|
||
|
||
hesabix_dump_archive_format_version() {
|
||
local dump="$1"
|
||
python3 - "${dump}" <<'PY'
|
||
import struct, sys
|
||
path = sys.argv[1]
|
||
try:
|
||
with open(path, "rb") as f:
|
||
if f.read(5) != b"PGDMP":
|
||
raise SystemExit(0)
|
||
vmaj, vmin = struct.unpack(">BB", f.read(2))
|
||
print(f"{vmaj}.{vmin}")
|
||
except OSError:
|
||
pass
|
||
PY
|
||
}
|
||
|
||
# Minimum pg_restore major required to read a custom archive format (not the dump source version).
|
||
hesabix_pg_major_for_archive_format() {
|
||
local fmt="${1:-}"
|
||
case "${fmt}" in
|
||
1.16) printf '%s' "17" ;;
|
||
1.15|1.14) printf '%s' "16" ;;
|
||
1.13) printf '%s' "15" ;;
|
||
1.12) printf '%s' "14" ;;
|
||
1.11|1.10) printf '%s' "13" ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
hesabix_detect_postgresql_server_major() {
|
||
local ver_num v
|
||
ver_num=$(sudo -u postgres psql -tAc "SHOW server_version_num;" 2>/dev/null | tr -d '[:space:]')
|
||
if [[ -n "${ver_num}" ]] && [[ "${ver_num}" =~ ^[0-9]+$ ]]; then
|
||
echo $((10#${ver_num} / 10000))
|
||
return 0
|
||
fi
|
||
detect_postgresql_major_version_for_config 2>/dev/null || return 1
|
||
}
|
||
|
||
hesabix_list_installed_pg_client_majors() {
|
||
local m bin
|
||
for m in 18 17 16 15 14 13; do
|
||
bin="/usr/lib/postgresql/${m}/bin/pg_restore"
|
||
[[ -x "${bin}" ]] && echo "${m}"
|
||
done
|
||
}
|
||
|
||
hesabix_ensure_pgdg_apt_repo() {
|
||
if [[ -f /etc/apt/sources.list.d/pgdg.list ]]; then
|
||
return 0
|
||
fi
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
apt-get install -y curl ca-certificates gnupg lsb-release >/dev/null 2>&1 || true
|
||
install -d /usr/share/postgresql-common/pgdg
|
||
if ! curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||
| gpg --batch --yes --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
local codename
|
||
codename=$(. /etc/os-release 2>/dev/null && printf '%s' "${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}")
|
||
[[ -z "${codename}" ]] && return 1
|
||
printf '%s\n' \
|
||
"deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
|
||
> /etc/apt/sources.list.d/pgdg.list
|
||
apt-get update -y >/dev/null 2>&1 || return 1
|
||
}
|
||
|
||
hesabix_ensure_postgresql_client_major() {
|
||
local major="$1"
|
||
local bin="/usr/lib/postgresql/${major}/bin/pg_restore"
|
||
[[ -x "${bin}" ]] && return 0
|
||
log_info "Installing postgresql-client-${major} (required to read seed dump format)..."
|
||
hesabix_ensure_pgdg_apt_repo || return 1
|
||
apt-get install -y "postgresql-client-${major}" || return 1
|
||
[[ -x "${bin}" ]]
|
||
}
|
||
|
||
# Pick pg_restore that can read the dump and preferably matches the target server major
|
||
# (pg_restore newer than server may emit SET transaction_timeout on PG < 17).
|
||
hesabix_resolve_pg_restore_for_dump() {
|
||
local dump="$1" fmt min_major server_major m bin install_m
|
||
local -a installed=()
|
||
fmt=$(hesabix_dump_archive_format_version "${dump}")
|
||
min_major=""
|
||
if [[ -n "${fmt}" ]]; then
|
||
min_major=$(hesabix_pg_major_for_archive_format "${fmt}" 2>/dev/null) || min_major=""
|
||
fi
|
||
server_major=$(hesabix_detect_postgresql_server_major 2>/dev/null) || server_major=""
|
||
while IFS= read -r m; do
|
||
[[ -n "${m}" ]] && installed+=("${m}")
|
||
done < <(hesabix_list_installed_pg_client_majors)
|
||
|
||
if [[ -n "${min_major}" ]]; then
|
||
if [[ -n "${server_major}" ]] && [[ "${server_major}" -ge "${min_major}" ]]; then
|
||
bin="/usr/lib/postgresql/${server_major}/bin/pg_restore"
|
||
if [[ -x "${bin}" ]]; then
|
||
log_info "pg_restore: PostgreSQL ${server_major} client (matches server; dump format ${fmt})."
|
||
printf '%s' "${bin}"
|
||
return 0
|
||
fi
|
||
fi
|
||
if [[ -n "${server_major}" ]]; then
|
||
for m in "${installed[@]}"; do
|
||
if [[ "${m}" -ge "${min_major}" ]] && [[ "${m}" -le "${server_major}" ]]; then
|
||
log_info "pg_restore: PostgreSQL ${m} client (dump format ${fmt}; server ${server_major})."
|
||
printf '%s' "/usr/lib/postgresql/${m}/bin/pg_restore"
|
||
return 0
|
||
fi
|
||
done
|
||
fi
|
||
for m in "${installed[@]}"; do
|
||
if [[ "${m}" -ge "${min_major}" ]]; then
|
||
if [[ -n "${server_major}" ]] && [[ "${m}" -gt "${server_major}" ]]; then
|
||
log_warning "pg_restore ${m} for dump format ${fmt} on PostgreSQL ${server_major} server — may cause transaction_timeout errors; prefer matching client or upgrade PostgreSQL."
|
||
fi
|
||
printf '%s' "/usr/lib/postgresql/${m}/bin/pg_restore"
|
||
return 0
|
||
fi
|
||
done
|
||
install_m="${min_major}"
|
||
if [[ -n "${server_major}" ]] && [[ "${server_major}" -ge "${min_major}" ]]; then
|
||
install_m="${server_major}"
|
||
fi
|
||
hesabix_ensure_postgresql_client_major "${install_m}" || true
|
||
bin="/usr/lib/postgresql/${install_m}/bin/pg_restore"
|
||
if [[ -x "${bin}" ]]; then
|
||
printf '%s' "${bin}"
|
||
return 0
|
||
fi
|
||
for m in 18 17 16 15 14 13; do
|
||
[[ "${m}" -ge "${min_major}" ]] || continue
|
||
hesabix_ensure_postgresql_client_major "${m}" || continue
|
||
bin="/usr/lib/postgresql/${m}/bin/pg_restore"
|
||
if [[ -x "${bin}" ]]; then
|
||
if [[ -n "${server_major}" ]] && [[ "${m}" -gt "${server_major}" ]]; then
|
||
log_warning "Installed pg_restore ${m} for format ${fmt}; target server is PostgreSQL ${server_major}."
|
||
fi
|
||
printf '%s' "${bin}"
|
||
return 0
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [[ -n "${server_major}" ]]; then
|
||
bin="/usr/lib/postgresql/${server_major}/bin/pg_restore"
|
||
[[ -x "${bin}" ]] && { printf '%s' "${bin}"; return 0; }
|
||
fi
|
||
for m in 18 17 16 15 14 13; do
|
||
bin="/usr/lib/postgresql/${m}/bin/pg_restore"
|
||
[[ -x "${bin}" ]] && { printf '%s' "${bin}"; return 0; }
|
||
done
|
||
command -v pg_restore 2>/dev/null || true
|
||
}
|
||
|
||
# Revision bundled with seed dump (sidecar file, env, or embedded alembic_version data).
|
||
hesabix_read_seed_alembic_revision() {
|
||
local dump="$1" backup_dir="$2" rev="" f pg_restore_bin
|
||
if [[ -n "${HESABIX_SEED_ALEMBIC_REVISION:-}" ]]; then
|
||
printf '%s' "${HESABIX_SEED_ALEMBIC_REVISION}"
|
||
return 0
|
||
fi
|
||
for f in "${dump}.revision" "${backup_dir}/hesabix_seed.revision"; do
|
||
if [[ -f "${f}" ]]; then
|
||
rev=$(tr -d '[:space:]' < "${f}")
|
||
if [[ -n "${rev}" ]]; then
|
||
printf '%s' "${rev}"
|
||
return 0
|
||
fi
|
||
fi
|
||
done
|
||
if [[ -f "${dump}" ]]; then
|
||
pg_restore_bin=$(hesabix_resolve_pg_restore_for_dump "${dump}")
|
||
if [[ -n "${pg_restore_bin}" && -x "${pg_restore_bin}" ]]; then
|
||
rev=$(
|
||
"${pg_restore_bin}" -a -t alembic_version "${dump}" 2>/dev/null | awk '
|
||
/^COPY / { copy=1; next }
|
||
copy && $0 == "\\." { exit }
|
||
copy && $0 !~ /^--/ && NF { gsub(/\r/, ""); print; exit }
|
||
'
|
||
)
|
||
if [[ -n "${rev}" ]]; then
|
||
printf '%s' "${rev}"
|
||
return 0
|
||
fi
|
||
fi
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
hesabix_ensure_alembic_version_schema() {
|
||
log_info "Ensuring alembic_version schema compatibility..."
|
||
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=0 <<'SQL' 2>/dev/null || true
|
||
DO $$
|
||
BEGIN
|
||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='alembic_version') THEN
|
||
ALTER TABLE public.alembic_version ALTER COLUMN version_num TYPE VARCHAR(255);
|
||
ELSE
|
||
CREATE TABLE public.alembic_version (version_num VARCHAR(255) PRIMARY KEY);
|
||
END IF;
|
||
EXCEPTION WHEN OTHERS THEN NULL;
|
||
END $$;
|
||
SQL
|
||
}
|
||
|
||
hesabix_reset_public_schema_for_seed() {
|
||
local table_count="$1"
|
||
log_warning "Database is not fully initialized (${table_count} table(s), core schema missing)."
|
||
log_warning "Resetting public schema before seed import (safe for fresh install; no Hesabix data yet)."
|
||
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL'
|
||
DROP SCHEMA IF EXISTS public CASCADE;
|
||
CREATE SCHEMA public AUTHORIZATION hesabix;
|
||
GRANT ALL ON SCHEMA public TO hesabix;
|
||
GRANT ALL ON SCHEMA public TO public;
|
||
SQL
|
||
}
|
||
|
||
hesabix_run_pg_restore_seed() {
|
||
local seed_dump="$1" restore_log="$2"
|
||
local pg_restore_bin
|
||
pg_restore_bin=$(hesabix_resolve_pg_restore_for_dump "${seed_dump}")
|
||
if [[ -z "${pg_restore_bin}" || ! -x "${pg_restore_bin}" ]]; then
|
||
log_error "pg_restore not found."
|
||
return 1
|
||
fi
|
||
log_info "Using $(${pg_restore_bin} --version 2>&1 | head -1)"
|
||
sudo -u postgres "${pg_restore_bin}" -d hesabix --no-owner --no-acl "${seed_dump}" >>"${restore_log}" 2>&1
|
||
}
|
||
|
||
hesabix_seed_restore_failed_fatal() {
|
||
local restore_log="$1"
|
||
[[ -f "${restore_log}" ]] || return 1
|
||
grep -qE 'unsupported version|could not read from input file|input file appears to be a text format|unrecognized configuration parameter .transaction_timeout' "${restore_log}" 2>/dev/null
|
||
}
|
||
|
||
hesabix_seed_restore_client_server_mismatch() {
|
||
local restore_log="$1"
|
||
[[ -f "${restore_log}" ]] || return 1
|
||
grep -qE 'unrecognized configuration parameter .transaction_timeout' "${restore_log}" 2>/dev/null
|
||
}
|
||
|
||
hesabix_ensure_public_schema_owner() {
|
||
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL' 2>/dev/null || true
|
||
ALTER SCHEMA public OWNER TO hesabix;
|
||
GRANT ALL ON SCHEMA public TO hesabix;
|
||
GRANT CREATE ON SCHEMA public TO hesabix;
|
||
SQL
|
||
}
|
||
|
||
# pg_restore --no-owner leaves objects owned by postgres without ACLs.
|
||
# Grant and reassign owners so hesabix (app user) can run Alembic and the API.
|
||
hesabix_fixup_seed_object_privileges() {
|
||
local fixup_script="${DEPLOY_SCRIPT_DIR}/scripts/hesabix_fixup_db_privileges.sh"
|
||
if [[ -f "${fixup_script}" ]]; then
|
||
chmod +x "${fixup_script}" 2>/dev/null || true
|
||
bash "${fixup_script}"
|
||
return 0
|
||
fi
|
||
sudo -u postgres psql -d hesabix -v ON_ERROR_STOP=1 <<'SQL'
|
||
ALTER SCHEMA public OWNER TO hesabix;
|
||
GRANT ALL ON SCHEMA public TO hesabix;
|
||
GRANT ALL ON ALL TABLES IN SCHEMA public TO hesabix;
|
||
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO hesabix;
|
||
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO hesabix;
|
||
DO $$
|
||
DECLARE r RECORD;
|
||
BEGIN
|
||
FOR r IN
|
||
SELECT c.relname
|
||
FROM pg_class c
|
||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||
WHERE n.nspname = 'public'
|
||
AND c.relkind IN ('r', 'p', 'v', 'm')
|
||
AND pg_get_userbyid(c.relowner) = 'postgres'
|
||
LOOP
|
||
EXECUTE format('ALTER TABLE public.%I OWNER TO hesabix', r.relname);
|
||
END LOOP;
|
||
END $$;
|
||
SQL
|
||
}
|
||
|
||
hesabix_import_seed_database() {
|
||
local seed_dump="$1" backup_dir="$2"
|
||
local restore_log="${APP_ROOT}/pg_restore_seed.log"
|
||
echo "Importing seed database from: ${seed_dump}"
|
||
: > "${restore_log}"
|
||
if hesabix_run_pg_restore_seed "${seed_dump}" "${restore_log}"; then
|
||
log_success "Seed database imported successfully."
|
||
else
|
||
if hesabix_seed_restore_failed_fatal "${restore_log}"; then
|
||
if hesabix_seed_restore_client_server_mismatch "${restore_log}"; then
|
||
log_error "pg_restore client is newer than the PostgreSQL server (see ${restore_log})."
|
||
log_error "Use a pg_restore matching the server major (e.g. postgresql-client-16 on Ubuntu 24.04), or upgrade PostgreSQL."
|
||
log_error "Alternatively regenerate the seed dump with pg_dump from the same major as the target server."
|
||
else
|
||
log_error "Seed dump format is newer than pg_restore on this server (see ${restore_log})."
|
||
log_error "Install postgresql-client-N from PGDG when possible, or regenerate seed with pg_dump / plain SQL for this server version."
|
||
fi
|
||
exit 1
|
||
fi
|
||
if PGPASSWORD="${DB_PASSWORD}" psql -h 127.0.0.1 -p 5432 -U hesabix -d hesabix -c "SELECT 1" >/dev/null 2>&1; then
|
||
log_warning "Seed import finished with warnings (see ${restore_log})."
|
||
else
|
||
log_error "Error importing seed database. Check ${restore_log}"
|
||
exit 1
|
||
fi
|
||
fi
|
||
hesabix_fixup_seed_object_privileges
|
||
hesabix_validate_seed_schema
|
||
hesabix_ensure_alembic_revision_after_seed "${seed_dump}" "${backup_dir}"
|
||
}
|
||
|
||
hesabix_ensure_alembic_revision_after_seed() {
|
||
local seed_dump="$1" backup_dir="$2" current rev
|
||
current=$(hesabix_current_alembic_revision)
|
||
if [[ -n "${current}" ]]; then
|
||
log_info "Alembic revision after seed: ${current}"
|
||
return 0
|
||
fi
|
||
if ! rev=$(hesabix_read_seed_alembic_revision "${seed_dump}" "${backup_dir}"); then
|
||
log_warning "alembic_version empty after seed; Alembic will run incremental migrations from base."
|
||
return 0
|
||
fi
|
||
log_info "Stamping database to seed Alembic revision: ${rev}"
|
||
if ! alembic stamp "${rev}"; then
|
||
log_error "alembic stamp ${rev} failed"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
deploy_backend() {
|
||
log_step "Deploying backend..."
|
||
local api_dir="${APP_ROOT}/app/hesabixAPI"
|
||
|
||
if [[ ! -d "${api_dir}" ]]; then
|
||
log_error "Backend path not found: ${api_dir}"
|
||
exit 1
|
||
fi
|
||
|
||
cd "${api_dir}"
|
||
|
||
configure_pip_hesabix_mirror
|
||
set_pip_mirror_env
|
||
|
||
local backend_python
|
||
if ! backend_python=$(hesabix_resolve_backend_python); then
|
||
log_info "Python >= 3.11 not found; installing packages..."
|
||
if ! hesabix_install_backend_python_packages || ! backend_python=$(hesabix_resolve_backend_python); then
|
||
log_error "hesabix-api requires Python >= 3.11. Could not install or locate a suitable interpreter."
|
||
exit 1
|
||
fi
|
||
fi
|
||
if ! hesabix_ensure_backend_venv "${api_dir}" "${backend_python}"; then
|
||
log_error "Failed to create backend virtualenv with ${backend_python}"
|
||
exit 1
|
||
fi
|
||
if [[ "${HESABIX_VENV_RECREATED:-0}" == "1" ]]; then
|
||
log_info "Backend virtualenv created/rebuilt with Python >= 3.11."
|
||
fi
|
||
# shellcheck disable=SC1091
|
||
source .venv/bin/activate
|
||
log_info "Installing backend deps from PyPI: ${PIP_INDEX_URL}"
|
||
if pip install --upgrade pip setuptools wheel && pip install -e .; then
|
||
log_success "Backend dependencies installed from ${PIP_INDEX_URL}"
|
||
else
|
||
log_error "Failed to install backend dependencies from ${PIP_INDEX_URL}. Check mirror reachability or PIP_INDEX_URL override."
|
||
exit 1
|
||
fi
|
||
|
||
# env.example as base for first install; later deploys only merge keys (preserve secrets).
|
||
local env_file=".env"
|
||
if [[ ! -f "${env_file}" ]]; then
|
||
if [[ -f "env.example" ]]; then
|
||
cp env.example "${env_file}"
|
||
else
|
||
: > "${env_file}"
|
||
fi
|
||
fi
|
||
merge_hesabix_api_env_file "${env_file}"
|
||
local ensure_secrets="${DEPLOY_SCRIPT_DIR}/scripts/ensure_api_production_secrets.sh"
|
||
if [[ -f "${ensure_secrets}" ]]; then
|
||
chmod +x "${ensure_secrets}" 2>/dev/null || true
|
||
log_info "Ensuring API production secrets in .env..."
|
||
if APP_ROOT="${APP_ROOT}" bash "${ensure_secrets}"; then
|
||
log_success "API production secrets verified."
|
||
else
|
||
log_error "Failed to ensure API production secrets."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
if [[ "${INSTALL_VOICE:-N}" =~ ^[Yy]$ ]]; then
|
||
local voice_script="${DEPLOY_SCRIPT_DIR}/scripts/ensure_voice_chat.sh"
|
||
if [[ -f "${voice_script}" ]]; then
|
||
chmod +x "${voice_script}" 2>/dev/null || true
|
||
log_info "Installing AI voice chat prerequisites (local STT/TTS)..."
|
||
if INSTALL_VOICE=Y bash "${voice_script}" --non-interactive; then
|
||
log_success "AI voice chat dependencies installed."
|
||
else
|
||
log_warning "AI voice chat install failed (non-fatal). Run later: INSTALL_VOICE=Y bash ${voice_script}"
|
||
fi
|
||
else
|
||
log_warning "ensure_voice_chat.sh not found; skip voice install."
|
||
fi
|
||
fi
|
||
|
||
log_info "Database connection pool configured: pool_size=${DB_POOL_SIZE}, max_overflow=${DB_MAX_OVERFLOW}, timeout=30s, recycle=300s"
|
||
|
||
# Secure .env file permissions (contains sensitive data like DB_PASSWORD)
|
||
chmod 600 "${env_file}"
|
||
chown www-data:www-data "${env_file}"
|
||
|
||
# Verify database connection before init
|
||
echo "Verifying database connection..."
|
||
if ! .venv/bin/python -c "
|
||
import sys
|
||
sys.path.insert(0, '.')
|
||
from app.core.settings import get_settings
|
||
settings = get_settings()
|
||
from sqlalchemy import create_engine, text
|
||
engine = create_engine(settings.postgresql_dsn)
|
||
with engine.connect() as conn:
|
||
conn.execute(text('SELECT 1'))
|
||
print('Connection successful')
|
||
" 2>/dev/null; then
|
||
echo "$WARNING_MARK Database connection failed. Initial data import may fail."
|
||
fi
|
||
|
||
# Import seed when DB is empty, then always run migrations
|
||
local backup_dir="${APP_ROOT}/app/backup"
|
||
local seed_dump
|
||
seed_dump=$(ls -t "${backup_dir}"/hesabix_seed*.dump 2>/dev/null | head -1)
|
||
local table_count
|
||
table_count=$(sudo -u postgres psql -d hesabix -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';" 2>/dev/null | tr -d '[:space:]')
|
||
if [[ ! "${table_count}" =~ ^[0-9]+$ ]]; then
|
||
table_count=999
|
||
fi
|
||
|
||
if [[ -n "${seed_dump}" && -f "${seed_dump}" ]]; then
|
||
hesabix_ensure_public_schema_owner
|
||
if hesabix_db_is_fully_initialized; then
|
||
log_info "Database already initialized (${table_count} tables). Skipping seed import."
|
||
else
|
||
if [[ "${table_count}" -gt 0 ]]; then
|
||
hesabix_reset_public_schema_for_seed "${table_count}"
|
||
table_count=0
|
||
fi
|
||
hesabix_import_seed_database "${seed_dump}" "${backup_dir}"
|
||
table_count=$(sudo -u postgres psql -d hesabix -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';" 2>/dev/null | tr -d '[:space:]')
|
||
fi
|
||
elif [[ "${table_count}" -eq 0 ]]; then
|
||
echo "$WARNING_MARK Seed dump not found in ${backup_dir}/hesabix_seed*.dump"
|
||
elif ! hesabix_db_is_fully_initialized; then
|
||
log_error "Database has ${table_count} tables but core schema is missing and no seed dump was found."
|
||
log_error "Add hesabix_seed*.dump under ${backup_dir} or drop/recreate the hesabix database."
|
||
exit 1
|
||
else
|
||
log_info "Database already initialized (${table_count} tables). Skipping seed import."
|
||
fi
|
||
|
||
# pgvector (for servers where prereqs step was skipped earlier)
|
||
if [[ -f "${DEPLOY_SCRIPT_DIR}/scripts/ensure_pgvector.sh" ]]; then
|
||
chmod +x "${DEPLOY_SCRIPT_DIR}/scripts/ensure_pgvector.sh" 2>/dev/null || true
|
||
log_info "Ensuring PostgreSQL pgvector package before migrations..."
|
||
bash "${DEPLOY_SCRIPT_DIR}/scripts/ensure_pgvector.sh" || log_warning "pgvector install skipped (non-fatal)."
|
||
fi
|
||
|
||
# Always run migrations (after optional seed import, or when DB was already initialized)
|
||
if ! hesabix_db_is_fully_initialized && [[ "${table_count}" -gt 0 ]]; then
|
||
log_error "Core schema still missing after seed/migration prep (${table_count} tables)."
|
||
log_error "Check ${APP_ROOT}/pg_restore_seed.log or recreate the hesabix database."
|
||
exit 1
|
||
fi
|
||
log_info "Ensuring hesabix owns public schema objects (Alembic/API access)..."
|
||
hesabix_fixup_seed_object_privileges
|
||
hesabix_ensure_alembic_version_schema
|
||
log_step "Running Alembic migrations..."
|
||
if ! alembic upgrade head; then
|
||
echo "$CROSS_MARK Error running migrations"
|
||
exit 1
|
||
fi
|
||
log_success "Migrations completed."
|
||
|
||
# Check if www-data user exists
|
||
if ! id -u www-data >/dev/null 2>&1; then
|
||
echo "$WARNING_MARK User www-data not found. Creating user..."
|
||
useradd -r -s /bin/false www-data || true
|
||
fi
|
||
|
||
# Set ownership
|
||
chown -R www-data:www-data "${api_dir}"
|
||
chmod +x "${api_dir}/deployment/systemd/hesabix-api-prestart.sh" 2>/dev/null || true
|
||
|
||
# systemd service
|
||
# Type=simple: uvicorn does not send sd_notify; Type=notify would cause start timeout.
|
||
# TimeoutStartSec=300: app startup (WeasyPrint/imports) can take 60-90+ seconds.
|
||
cat > /etc/systemd/system/hesabix-api.service <<UNIT
|
||
[Unit]
|
||
Description=Hesabix API (FastAPI/Uvicorn)
|
||
After=network.target postgresql.service
|
||
Wants=postgresql.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
TimeoutStartSec=300
|
||
User=www-data
|
||
Group=www-data
|
||
WorkingDirectory=${api_dir}
|
||
Environment=PATH=/usr/bin:/usr/local/bin:${api_dir}/.venv/bin
|
||
Environment=PYTHONUNBUFFERED=1
|
||
Environment=TZ=UTC
|
||
# ! = run as root: daemon-reload + pip from deploy mirrors if needed (before alembic)
|
||
ExecStartPre=!${api_dir}/deployment/systemd/hesabix-api-prestart.sh
|
||
# Before start/restart: run migrations; service does not start if migrations fail
|
||
ExecStartPre=${api_dir}/.venv/bin/python -m alembic -c ${api_dir}/alembic.ini upgrade head
|
||
ExecStart=${api_dir}/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS}
|
||
Restart=always
|
||
RestartSec=10
|
||
StandardOutput=journal
|
||
StandardError=journal
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
UNIT
|
||
|
||
systemctl daemon-reload
|
||
|
||
# Stop service if running so the new unit definition is used on next start
|
||
if check_service hesabix-api; then
|
||
systemctl stop hesabix-api
|
||
fi
|
||
|
||
systemctl enable hesabix-api
|
||
systemctl start hesabix-api
|
||
|
||
# Wait for service to become active (startup with WeasyPrint can take 30-90s)
|
||
log_info "Waiting for hesabix-api to become active (up to 120s)..."
|
||
for i in $(seq 1 24); do
|
||
if systemctl is-active --quiet hesabix-api 2>/dev/null; then
|
||
log_success "Backend started (service: hesabix-api)."
|
||
break
|
||
fi
|
||
if [ "$i" -eq 24 ]; then
|
||
log_error "Backend failed to start within 120s. Check logs: journalctl -xeu hesabix-api"
|
||
exit 1
|
||
fi
|
||
sleep 5
|
||
done
|
||
|
||
# RQ Worker service for background jobs
|
||
# redis-server.service = Debian/Ubuntu; redis.service = other distros
|
||
cat > /etc/systemd/system/hesabix-rq-worker.service <<UNIT
|
||
[Unit]
|
||
Description=Hesabix RQ Worker (Background Jobs)
|
||
After=network.target postgresql.service redis-server.service redis.service
|
||
Wants=redis-server.service redis.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=www-data
|
||
Group=www-data
|
||
WorkingDirectory=${api_dir}
|
||
Environment=PATH=${api_dir}/.venv/bin
|
||
Environment=PYTHONUNBUFFERED=1
|
||
Environment=TZ=UTC
|
||
ExecStart=${api_dir}/.venv/bin/python ${api_dir}/rq_worker.py
|
||
Restart=on-failure
|
||
RestartSec=10
|
||
StandardOutput=journal
|
||
StandardError=journal
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
UNIT
|
||
|
||
systemctl daemon-reload
|
||
|
||
# Stop service if running to avoid conflicts
|
||
if check_service hesabix-rq-worker; then
|
||
systemctl stop hesabix-rq-worker
|
||
fi
|
||
|
||
systemctl enable hesabix-rq-worker
|
||
|
||
# Always start RQ worker: if Redis is disabled in system settings, the script exits 0
|
||
# and Restart=on-failure no longer causes a pointless restart loop.
|
||
systemctl start hesabix-rq-worker
|
||
sleep 2
|
||
if systemctl is-active --quiet hesabix-rq-worker 2>/dev/null; then
|
||
log_success "RQ Worker is running (hesabix-rq-worker)."
|
||
else
|
||
log_warning "RQ Worker is not active. If Redis is disabled in system settings, this is expected; enable Redis then: systemctl start hesabix-rq-worker"
|
||
log_warning "Otherwise check: journalctl -u hesabix-rq-worker -n 50"
|
||
fi
|
||
|
||
# Notification Moderation Worker service
|
||
# redis-server.service = Debian/Ubuntu; redis.service = other distros
|
||
cat > /etc/systemd/system/hesabix-notification-moderation.service <<UNIT
|
||
[Unit]
|
||
Description=Hesabix Notification Moderation Worker
|
||
Documentation=https://hesabix.com/docs/notification-moderation
|
||
After=network.target postgresql.service redis-server.service redis.service
|
||
Wants=postgresql.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=www-data
|
||
Group=www-data
|
||
WorkingDirectory=${api_dir}
|
||
Environment=PATH=${api_dir}/.venv/bin
|
||
Environment=PYTHONUNBUFFERED=1
|
||
Environment=TZ=UTC
|
||
Environment=PYTHONPATH=${api_dir}
|
||
ExecStart=${api_dir}/.venv/bin/python -m app.workers.notification_moderation_worker
|
||
Restart=always
|
||
RestartSec=10
|
||
StartLimitInterval=5min
|
||
StartLimitBurst=3
|
||
StandardOutput=journal
|
||
StandardError=journal
|
||
SyslogIdentifier=hesabix-notification-moderation
|
||
|
||
# Security
|
||
NoNewPrivileges=true
|
||
PrivateTmp=true
|
||
|
||
# Resource limits
|
||
MemoryMax=512M
|
||
CPUQuota=50%
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
UNIT
|
||
|
||
systemctl daemon-reload
|
||
|
||
# Stop service if running to avoid conflicts
|
||
if check_service hesabix-notification-moderation; then
|
||
systemctl stop hesabix-notification-moderation
|
||
fi
|
||
|
||
systemctl enable hesabix-notification-moderation
|
||
|
||
# Start notification moderation worker (uses DB; not the same as RQ/Redis)
|
||
systemctl start hesabix-notification-moderation
|
||
sleep 2
|
||
if check_service hesabix-notification-moderation; then
|
||
log_success "Notification Moderation Worker started (service: hesabix-notification-moderation)."
|
||
else
|
||
log_warning "Notification Moderation Worker failed to start. Check logs: journalctl -u hesabix-notification-moderation"
|
||
fi
|
||
}
|
||
|
||
# pip / Flutter mirrors: scripts/mirror_config.sh or inline fallback (curl installer)
|
||
|
||
# Persist Flutter PATH for new shells (login / bash --login). POSIX-sh file for /etc/profile.d.
|
||
persist_flutter_path_in_profile_d() {
|
||
local f="/etc/profile.d/hesabix-flutter.sh"
|
||
if [[ ! -x /opt/flutter/bin/flutter && ! -x /snap/bin/flutter ]]; then
|
||
return 0
|
||
fi
|
||
cat > "${f}" <<'PROFILE'
|
||
# Hesabix: add Flutter to PATH for login shells (deploy.sh / update.sh); avoid manual edits if possible.
|
||
if [ -x /opt/flutter/bin/flutter ]; then
|
||
case ":${PATH}:" in
|
||
*:/opt/flutter/bin:*) ;;
|
||
*) PATH="/opt/flutter/bin${PATH:+:$PATH}"; export PATH ;;
|
||
esac
|
||
fi
|
||
if [ -x /snap/bin/flutter ]; then
|
||
case ":${PATH}:" in
|
||
*:/snap/bin:*) ;;
|
||
*) PATH="/snap/bin${PATH:+:$PATH}"; export PATH ;;
|
||
esac
|
||
fi
|
||
PROFILE
|
||
chmod 644 "${f}" 2>/dev/null || true
|
||
log_success "Flutter added to PATH for login shells: ${f}"
|
||
|
||
# Graphical Ubuntu/Debian terminals are usually non-login; idempotent line in bash.bashrc
|
||
local marker="# hesabix-flutter-PATH (deploy.sh)"
|
||
if [[ -f /etc/bash.bashrc ]] && ! grep -qF "${marker}" /etc/bash.bashrc 2>/dev/null; then
|
||
printf '\n%s\n[ -r /etc/profile.d/hesabix-flutter.sh ] && . /etc/profile.d/hesabix-flutter.sh\n' "${marker}" >> /etc/bash.bashrc
|
||
log_success "Interactive bash: sourced ${f} from /etc/bash.bashrc."
|
||
fi
|
||
}
|
||
|
||
# After curl, validate .tar.xz (truncated or corrupted transfers can still exit 0).
|
||
hesabix_verify_flutter_sdk_tarball() {
|
||
local f="$1"
|
||
local err
|
||
[[ -f "$f" ]] || return 1
|
||
if [[ -n "${FLUTTER_SDK_TARBALL_SHA256:-}" ]]; then
|
||
if printf '%s %s\n' "${FLUTTER_SDK_TARBALL_SHA256}" "$f" | sha256sum -c --status 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
log_warning "SHA256 mismatch for ${f} (expected FLUTTER_SDK_TARBALL_SHA256 from mirror/docs)."
|
||
return 1
|
||
fi
|
||
err="$(mktemp -t flutter_tar_test.XXXXXX 2>/dev/null || echo /tmp/flutter_tar_test.err)"
|
||
log_info "Verifying tarball integrity (tar -tJf; may take 1-3 min for ~1GB)..."
|
||
if ! tar -tJf "$f" >/dev/null 2>"${err}"; then
|
||
log_warning "Tarball integrity check failed (corrupt or incomplete file). First lines: $(head -3 "${err}" 2>/dev/null | tr '\n' ' ')"
|
||
rm -f "${err}" 2>/dev/null || true
|
||
return 1
|
||
fi
|
||
rm -f "${err}" 2>/dev/null || true
|
||
return 0
|
||
}
|
||
|
||
# Ensure Flutter SDK is available (install if missing). Exports PATH for current shell.
|
||
# Must be called after set_flutter_mirror_env so first-run Dart SDK download uses mirror.
|
||
# Order: 1) Existing 2) internal mirror (FLUTTER_SDK_TARBALL_URL_INTERNAL) 3) Snap 4) Git clone.
|
||
ensure_flutter_sdk() {
|
||
local opt_flutter="/opt/flutter/bin"
|
||
local use_mirror=0
|
||
if [[ -n "${FLUTTER_STORAGE_BASE_URL:-}" && "${FLUTTER_STORAGE_BASE_URL}" != *"storage.googleapis.com"* ]]; then
|
||
use_mirror=1
|
||
log_info "Mirror is set (non-Google); /opt/flutter will be preferred for engine downloads."
|
||
fi
|
||
|
||
# 1) Use existing Flutter if already available
|
||
# With non-Google mirror, only /opt/flutter is acceptable (snap downloads from storage.googleapis.com)
|
||
if [[ $use_mirror -eq 1 ]]; then
|
||
if [[ -x "${opt_flutter}/flutter" ]]; then
|
||
export PATH="${opt_flutter}:$PATH"
|
||
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
|
||
log_info "Using Flutter from ${opt_flutter} (mirror mode; snap ignored so engine uses mirror)"
|
||
return 0
|
||
fi
|
||
log_info "Mirror mode: skipping snap Flutter; will use /opt/flutter (tarball or git clone)."
|
||
else
|
||
if command -v flutter >/dev/null 2>&1; then
|
||
log_info "Flutter found in PATH: $(command -v flutter)"
|
||
return 0
|
||
fi
|
||
if [[ -x "${opt_flutter}/flutter" ]]; then
|
||
export PATH="${opt_flutter}:$PATH"
|
||
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
|
||
log_info "Using Flutter from ${opt_flutter}"
|
||
return 0
|
||
fi
|
||
local snap_bin="$HOME/snap/flutter/current/flutter/bin"
|
||
local snap_bin_common="$HOME/snap/flutter/common/flutter/bin"
|
||
if [[ -d "${snap_bin}" && -x "${snap_bin}/flutter" ]]; then
|
||
export PATH="${snap_bin}:$PATH"
|
||
log_info "Using Flutter from snap (current): ${snap_bin}"
|
||
return 0
|
||
fi
|
||
if [[ -d "${snap_bin_common}" && -x "${snap_bin_common}/flutter" ]]; then
|
||
export PATH="${snap_bin_common}:$PATH"
|
||
log_info "Using Flutter from snap (common): ${snap_bin_common}"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# 2) First: internal mirror (exact FLUTTER_SDK_TARBALL_URL_INTERNAL) — SDK only; pub packages use mirror
|
||
if [[ ! -d /opt/flutter ]]; then
|
||
log_info "Trying Flutter SDK from internal mirror (first): ${FLUTTER_SDK_TARBALL_URL_INTERNAL}"
|
||
apt-get install -y -qq curl xz-utils >/dev/null 2>&1 || true
|
||
local tarball_ok=0
|
||
local flutter_curl_maxtime=()
|
||
if [[ -n "${FLUTTER_SDK_DOWNLOAD_MAX_TIME:-}" && "${FLUTTER_SDK_DOWNLOAD_MAX_TIME}" != "0" ]]; then
|
||
flutter_curl_maxtime=(--max-time "${FLUTTER_SDK_DOWNLOAD_MAX_TIME}")
|
||
fi
|
||
local flutter_curl_http=()
|
||
[[ "${FLUTTER_SDK_CURL_HTTP11:-1}" != "0" ]] && flutter_curl_http=(--http1.1)
|
||
local flutter_tb="/tmp/flutter_sdk.tar.xz"
|
||
local tb_attempt tb_max="${FLUTTER_SDK_TARBALL_ATTEMPTS:-3}"
|
||
[[ "${tb_max}" =~ ^[0-9]+$ ]] && [[ "${tb_max}" -lt 1 ]] && tb_max=1
|
||
[[ "${tb_max}" =~ ^[0-9]+$ ]] || tb_max=3
|
||
for ((tb_attempt = 1; tb_attempt <= tb_max; tb_attempt++)); do
|
||
log_info "Flutter tarball attempt ${tb_attempt}/${tb_max}: download (progress, resume) then verify+extract."
|
||
log_info "curl: connect_timeout=${FLUTTER_SDK_CONNECT_TIMEOUT}s max_time=${FLUTTER_SDK_DOWNLOAD_MAX_TIME:-0}s http1.1=${FLUTTER_SDK_CURL_HTTP11:-1}"
|
||
local flutter_curl_ok=0
|
||
if curl -fL "${flutter_curl_maxtime[@]}" "${flutter_curl_http[@]}" --connect-timeout "${FLUTTER_SDK_CONNECT_TIMEOUT}" \
|
||
--retry 5 --retry-delay 8 --retry-connrefused \
|
||
--progress-bar -C - -o "${flutter_tb}" \
|
||
-w '\n[curl] finished: %{size_download} bytes in %{time_total}s (avg %{speed_download} B/s)\n' \
|
||
"${FLUTTER_SDK_TARBALL_URL_INTERNAL}"; then
|
||
flutter_curl_ok=1
|
||
fi
|
||
if [[ "${flutter_curl_ok}" -ne 1 ]]; then
|
||
log_warning "Download failed (attempt ${tb_attempt}/${tb_max})."
|
||
rm -f "${flutter_tb}"
|
||
continue
|
||
fi
|
||
if ! hesabix_verify_flutter_sdk_tarball "${flutter_tb}"; then
|
||
log_warning "Removing corrupt/incomplete tarball; retrying download (attempt ${tb_attempt}/${tb_max})."
|
||
rm -f "${flutter_tb}"
|
||
continue
|
||
fi
|
||
local tar_xz_err
|
||
tar_xz_err="$(mktemp -t flutter_tar_extract.XXXXXX 2>/dev/null || echo /tmp/flutter_tar_extract.err)"
|
||
if tar -xJf "${flutter_tb}" -C /opt 2>"${tar_xz_err}"; then
|
||
rm -f "${flutter_tb}" "${tar_xz_err}" 2>/dev/null || true
|
||
if [[ -d /opt/flutter && -x /opt/flutter/bin/flutter ]]; then
|
||
tarball_ok=1
|
||
else
|
||
local single_dir
|
||
single_dir=$(ls -1 /opt 2>/dev/null | grep -E '^flutter' | head -1)
|
||
if [[ -n "${single_dir}" && -d "/opt/${single_dir}" && -x "/opt/${single_dir}/bin/flutter" ]]; then
|
||
mv "/opt/${single_dir}" /opt/flutter 2>/dev/null && tarball_ok=1
|
||
fi
|
||
fi
|
||
if [[ $tarball_ok -eq 1 ]]; then
|
||
break
|
||
fi
|
||
log_warning "Tarball verified but Flutter binary not found after extract; cleaning and retrying."
|
||
rm -rf /opt/flutter /opt/flutter_linux* 2>/dev/null || true
|
||
rm -f "${flutter_tb}" 2>/dev/null || true
|
||
else
|
||
log_warning "tar -xJf failed: $(head -5 "${tar_xz_err}" 2>/dev/null | tr '\n' ' ')"
|
||
rm -f "${tar_xz_err}" 2>/dev/null || true
|
||
rm -rf /opt/flutter /opt/flutter_linux* 2>/dev/null || true
|
||
rm -f "${flutter_tb}"
|
||
fi
|
||
done
|
||
if [[ $tarball_ok -eq 0 ]]; then
|
||
log_info "Internal tarball path exhausted after ${tb_max} attempt(s); trying next method."
|
||
fi
|
||
if [[ $tarball_ok -eq 1 ]]; then
|
||
export PATH="/opt/flutter/bin:$PATH"
|
||
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
|
||
log_success "Flutter SDK installed from internal mirror (${FLUTTER_SDK_TARBALL_URL_INTERNAL}). Pub packages will use PUB_HOSTED_URL."
|
||
log_info "Running flutter doctor (first run may download packages from mirror)..."
|
||
if ! flutter doctor -v 2>&1; then
|
||
log_warning "flutter doctor had issues; continuing. Packages will be fetched via mirror during build."
|
||
fi
|
||
log_success "Flutter SDK ready at /opt/flutter."
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
# 3) Official install: snap
|
||
if command -v snap >/dev/null 2>&1 && ! snap list flutter 2>/dev/null | grep -q flutter; then
|
||
log_info "Trying official install: snap install flutter (this may take a few minutes)..."
|
||
if snap install flutter --classic 2>/dev/null; then
|
||
export PATH="/snap/bin:$PATH"
|
||
if command -v flutter >/dev/null 2>&1; then
|
||
log_success "Flutter installed via snap (official)."
|
||
return 0
|
||
fi
|
||
else
|
||
log_info "Snap install failed or unavailable; will try git clone next."
|
||
fi
|
||
fi
|
||
|
||
# 4) Git clone to /opt/flutter (official GitHub first, then alternative mirrors)
|
||
log_info "Installing Flutter SDK via git clone..."
|
||
apt-get install -y -qq git curl unzip xz-utils zip libglu1-mesa >/dev/null 2>&1 || true
|
||
if [[ ! -d /opt/flutter ]]; then
|
||
local flutter_cloned=0
|
||
# 4a) Official source first: GitHub
|
||
log_info "Trying to clone Flutter SDK from official source (GitHub)..."
|
||
if git clone --depth 1 --branch stable "https://github.com/flutter/flutter.git" /opt/flutter 2>/dev/null; then
|
||
flutter_cloned=1
|
||
log_success "Flutter SDK cloned from official source (GitHub)."
|
||
fi
|
||
# 2) If official failed, try alternative mirrors
|
||
if [[ $flutter_cloned -eq 0 ]]; then
|
||
log_info "Official source failed; trying alternative mirrors..."
|
||
local flutter_git_urls=()
|
||
[[ -n "${FLUTTER_SDK_GIT_URL:-}" ]] && flutter_git_urls+=("${FLUTTER_SDK_GIT_URL}")
|
||
flutter_git_urls+=(
|
||
"https://mirrors.tuna.tsinghua.edu.cn/git/flutter-sdk.git"
|
||
"https://gitee.com/mirrors/Flutter.git"
|
||
)
|
||
for repo_url in "${flutter_git_urls[@]}"; do
|
||
log_info "Trying alternative: ${repo_url} ..."
|
||
if git clone --depth 1 --branch stable "${repo_url}" /opt/flutter 2>/dev/null; then
|
||
flutter_cloned=1
|
||
log_success "Flutter SDK cloned from alternative: ${repo_url}"
|
||
break
|
||
fi
|
||
rm -rf /opt/flutter 2>/dev/null || true
|
||
done
|
||
fi
|
||
if [[ $flutter_cloned -eq 0 ]]; then
|
||
log_error "Failed to install Flutter SDK (internal tarball, snap, and git clone all failed). Internal URL tried first: ${FLUTTER_SDK_TARBALL_URL_INTERNAL}. For git clone you can set FLUTTER_SDK_GIT_URL. See: https://docs.flutter.dev/get-started/install/linux"
|
||
exit 1
|
||
fi
|
||
else
|
||
local ensure_flutter_script="${DEPLOY_SCRIPT_DIR}/scripts/ensure_flutter_sdk_for_update.sh"
|
||
export HESABIX_UPDATE_FLUTTER_SDK="${HESABIX_UPDATE_FLUTTER_SDK:-1}"
|
||
if [[ -f "${ensure_flutter_script}" ]]; then
|
||
chmod +x "${ensure_flutter_script}" 2>/dev/null || true
|
||
if ! bash "${ensure_flutter_script}"; then
|
||
log_warning "Flutter SDK ensure failed after git update; trying flutter doctor..."
|
||
fi
|
||
else
|
||
(cd /opt/flutter && git fetch --depth 1 origin stable && git reset --hard origin/stable) 2>/dev/null || true
|
||
fi
|
||
fi
|
||
export PATH="/opt/flutter/bin:$PATH"
|
||
git config --global --add safe.directory /opt/flutter 2>/dev/null || true
|
||
if ! command -v flutter >/dev/null 2>&1; then
|
||
log_error "Flutter binary not found after install. Check /opt/flutter/bin."
|
||
exit 1
|
||
fi
|
||
log_info "Running flutter doctor (first run may download Dart SDK from mirror)..."
|
||
if ! flutter doctor -v 2>&1; then
|
||
log_warning "flutter doctor had issues; continuing. If build fails, check f.mirror.hesabix.ir and hesabixAPI/f.mirror.hesabix.ir.conf."
|
||
fi
|
||
log_success "Flutter SDK ready at /opt/flutter."
|
||
}
|
||
|
||
# On low-RAM servers, add swap so Flutter/dart2js build is not OOM-killed (exit -9).
|
||
ensure_swap_for_flutter_build() {
|
||
local total_mb avail_mb avail_kb
|
||
if [[ -r /proc/meminfo ]]; then
|
||
total_mb=$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo)
|
||
avail_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)
|
||
if [[ -n "${avail_kb}" ]] && [[ "${avail_kb}" =~ ^[0-9]+$ ]]; then
|
||
avail_mb=$((avail_kb / 1024))
|
||
else
|
||
avail_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $7}')
|
||
fi
|
||
else
|
||
total_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $2}')
|
||
avail_mb=$(free -m 2>/dev/null | awk '/^Mem:/ {print $7}')
|
||
fi
|
||
total_mb=${total_mb:-0}
|
||
avail_mb=${avail_mb:-0}
|
||
if [ "${total_mb}" -lt 2500 ] || [ "${avail_mb}" -lt 1000 ]; then
|
||
local swapfile="${APP_ROOT}/swap.flutter.bin"
|
||
# On <2.5GB RAM use 2.5GB swap so dart compile js can finish
|
||
local swap_mb=2560
|
||
[ "${total_mb:-0}" -ge 2500 ] && swap_mb=1536
|
||
local swap_bytes=$((swap_mb * 1024 * 1024))
|
||
if [ ! -f "$swapfile" ] || [ "$(stat -c%s "$swapfile" 2>/dev/null)" -lt "$swap_bytes" ]; then
|
||
log_info "Low memory (total ${total_mb}MB, available ${avail_mb}MB). Creating ${swap_mb}MB swap for Flutter build..."
|
||
dd if=/dev/zero of="$swapfile" bs=1M count="$swap_mb" status=none 2>/dev/null || true
|
||
chmod 600 "$swapfile"
|
||
mkswap "$swapfile" 2>/dev/null || true
|
||
fi
|
||
swapon "$swapfile" 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
install_flutter_and_build_frontend() {
|
||
log_step "Building Flutter frontend..."
|
||
set_flutter_mirror_env
|
||
ensure_flutter_sdk
|
||
ensure_swap_for_flutter_build
|
||
export PATH="/opt/flutter/bin:/snap/bin:$PATH"
|
||
if ! command -v flutter >/dev/null 2>&1; then
|
||
log_error "Flutter not in PATH after ensure_flutter_sdk. PATH=$PATH"
|
||
exit 1
|
||
fi
|
||
persist_flutter_path_in_profile_d
|
||
|
||
local app_dir="${APP_ROOT}/app"
|
||
if [[ ! -d "${app_dir}" ]]; then
|
||
log_error "App directory not found: ${app_dir}"
|
||
exit 1
|
||
fi
|
||
|
||
local build_script="${app_dir}/build_web.sh"
|
||
if [[ ! -f "${build_script}" ]]; then
|
||
log_error "build_web.sh not found: ${build_script}"
|
||
exit 1
|
||
fi
|
||
|
||
# Make build script executable
|
||
chmod +x "${build_script}"
|
||
|
||
local api_scheme
|
||
api_scheme="$(hesabix_resolve_api_public_scheme)"
|
||
local api_url="${api_scheme}://${API_DOMAIN}"
|
||
|
||
echo "Building Flutter web with:"
|
||
echo " Mode: release"
|
||
echo " API URL: ${api_url} (scheme from TLS detection or API_PUBLIC_SCHEME)"
|
||
echo " Output: /var/www/${UI_DOMAIN}"
|
||
echo
|
||
echo "$CHECK_MARK Flutter build uses mirror: ${PUB_HOSTED_URL:-f.mirror.hesabix.ir}"
|
||
|
||
cd "${app_dir}"
|
||
log_info "Building Flutter web (pub/storage via ${PUB_HOSTED_URL})"
|
||
if ! env PATH="/opt/flutter/bin:/snap/bin:$PATH" \
|
||
PUB_HOSTED_URL="${PUB_HOSTED_URL}" FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL}" \
|
||
bash build_web.sh \
|
||
--mode release \
|
||
--api-base-url "${api_url}" \
|
||
--clean \
|
||
--install-deps; then
|
||
log_error "Flutter build failed. Check network, DNS, and pub mirror (${PUB_HOSTED_URL:-unknown})."
|
||
exit 1
|
||
fi
|
||
log_success "Flutter web build succeeded"
|
||
|
||
# Find the build output directory
|
||
local ui_project_dir="${app_dir}/hesabixUI/hesabix_ui"
|
||
local build_output="${ui_project_dir}/build/web"
|
||
|
||
log_info "Checking build output..."
|
||
log_info " Build directory: ${build_output}"
|
||
|
||
if [[ ! -d "${build_output}" ]]; then
|
||
log_error "Build output directory not found: ${build_output}"
|
||
exit 1
|
||
fi
|
||
|
||
# Verify that index.html exists
|
||
if [[ ! -f "${build_output}/index.html" ]]; then
|
||
log_error "index.html not found in build output. Build may have failed."
|
||
exit 1
|
||
fi
|
||
|
||
log_success "Build output verified (${build_output})"
|
||
|
||
# Deploy to web directory
|
||
log_info "Deploying to web directory..."
|
||
log_info " Source: ${build_output}/"
|
||
log_info " Destination: /var/www/${UI_DOMAIN}/"
|
||
|
||
mkdir -p "/var/www/${UI_DOMAIN}"
|
||
rsync -a --delete "${build_output}/" "/var/www/${UI_DOMAIN}/"
|
||
|
||
# Verify deployment
|
||
if [[ ! -f "/var/www/${UI_DOMAIN}/index.html" ]]; then
|
||
log_error "Deployment failed: index.html not found in destination"
|
||
exit 1
|
||
fi
|
||
|
||
chown -R www-data:www-data "/var/www/${UI_DOMAIN}"
|
||
log_success "Frontend built and deployed to /var/www/${UI_DOMAIN}."
|
||
}
|
||
|
||
configure_nginx_api() {
|
||
echo ">> Configuring Nginx for API..."
|
||
|
||
# Check if nginx is installed
|
||
if ! command -v nginx >/dev/null 2>&1; then
|
||
echo "$CROSS_MARK Nginx is not installed"
|
||
exit 1
|
||
fi
|
||
|
||
# Remove default site if exists
|
||
if [[ -L /etc/nginx/sites-enabled/default ]]; then
|
||
rm -f /etc/nginx/sites-enabled/default
|
||
fi
|
||
|
||
# Create rate limiting zone configuration (included in http context)
|
||
if [[ -d /etc/nginx/conf.d ]]; then
|
||
cat > /etc/nginx/conf.d/rate-limit-api.conf <<RATELIMIT
|
||
# Rate limiting zone for API
|
||
# OPTIONS (CORS preflight) is not counted in quota; each XHR is usually OPTIONS + METHOD.
|
||
map \$request_method \$api_limit_key {
|
||
default \$binary_remote_addr;
|
||
OPTIONS "";
|
||
}
|
||
|
||
limit_req_zone \$api_limit_key zone=api_limit:10m rate=40r/s;
|
||
RATELIMIT
|
||
fi
|
||
|
||
# Create API-specific configuration
|
||
cat > /etc/nginx/sites-available/hesabix-api.conf <<NGINX
|
||
# Backend API
|
||
server {
|
||
listen 80;
|
||
server_name ${API_DOMAIN};
|
||
|
||
# Security headers
|
||
add_header X-Frame-Options "DENY" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header X-XSS-Protection "1; mode=block" always;
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
|
||
# Root route (service info and version)
|
||
location = / {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
}
|
||
|
||
# Public share link: /p/{code} → backend (307 redirect)
|
||
location /p/ {
|
||
proxy_pass http://127.0.0.1:8000/p/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 30;
|
||
proxy_connect_timeout 10;
|
||
proxy_send_timeout 30;
|
||
}
|
||
|
||
# Invoice/document share link: /i/{code} → backend (307 redirect to /public/invoice-link/ in Flutter)
|
||
location /i/ {
|
||
proxy_pass http://127.0.0.1:8000/i/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 30;
|
||
proxy_connect_timeout 10;
|
||
proxy_send_timeout 30;
|
||
}
|
||
|
||
# When API and UI share one domain: serve /public/ paths from UI root (SPA)
|
||
# Named location fallback so /index.html does not hit location / { return 404; }
|
||
location /public/ {
|
||
root /var/www/${UI_DOMAIN};
|
||
try_files \$uri \$uri/ @hesabix_public_spa;
|
||
}
|
||
location @hesabix_public_spa {
|
||
root /var/www/${UI_DOMAIN};
|
||
rewrite ^ /index.html break;
|
||
}
|
||
|
||
# Swagger / OpenAPI from backend (^~ for /docs/* e.g. oauth2-redirect; /docs-custom under same prefix)
|
||
location ^~ /docs {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location = /openapi.json {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location ^~ /redoc {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
# Flutter web /assets (MaterialIcons fonts, shaders, …) must not always proxy to API;
|
||
# otherwise when API_DOMAIN and UI_DOMAIN match (merged nginx server), web icons/assets return empty/404.
|
||
location ^~ /assets/swagger/ {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location = /assets/logo-blue.png {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location ^~ /assets/icons/ {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location /assets/ {
|
||
root /var/www/${UI_DOMAIN};
|
||
try_files \$uri \$uri/ @hesabix_api_assets_fallback;
|
||
}
|
||
location @hesabix_api_assets_fallback {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location / {
|
||
return 404;
|
||
}
|
||
|
||
# Public web chat: rate limits only in app (firewall_rate_policies table)
|
||
location /api/v1/public/crm-chat/ {
|
||
proxy_pass http://127.0.0.1:8000/api/v1/public/crm-chat/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
client_max_body_size 1g;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
}
|
||
|
||
# AI chat SSE: stream chunks immediately (no proxy buffering)
|
||
location ^~ /api/v1/ai/chat/ {
|
||
limit_req zone=api_limit burst=120 nodelay;
|
||
proxy_pass http://127.0.0.1:8000/api/v1/ai/chat/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 600;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 600;
|
||
client_max_body_size 1g;
|
||
proxy_buffering off;
|
||
proxy_request_buffering off;
|
||
proxy_cache off;
|
||
gzip off;
|
||
add_header X-Accel-Buffering no always;
|
||
}
|
||
|
||
location /api/ {
|
||
limit_req zone=api_limit burst=120 nodelay;
|
||
proxy_pass http://127.0.0.1:8000/api/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
client_max_body_size 1g;
|
||
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
}
|
||
|
||
# WebSocket endpoints (/ws/notifications, /ws/ai/voice, etc.)
|
||
location /ws/ {
|
||
proxy_pass http://127.0.0.1:8000/ws/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 86400;
|
||
proxy_send_timeout 86400;
|
||
}
|
||
}
|
||
NGINX
|
||
|
||
ln -sf /etc/nginx/sites-available/hesabix-api.conf /etc/nginx/sites-enabled/hesabix-api.conf
|
||
|
||
# Test nginx configuration
|
||
if ! nginx -t; then
|
||
echo "$CROSS_MARK Error in Nginx configuration"
|
||
exit 1
|
||
fi
|
||
|
||
# Reload nginx
|
||
if systemctl reload nginx; then
|
||
echo "$CHECK_MARK Nginx configured and reloaded for API."
|
||
else
|
||
echo "$CROSS_MARK Error reloading Nginx"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
configure_nginx_ui() {
|
||
echo ">> Configuring Nginx for UI..."
|
||
|
||
# Check if nginx is installed
|
||
if ! command -v nginx >/dev/null 2>&1; then
|
||
echo "$CROSS_MARK Nginx is not installed"
|
||
exit 1
|
||
fi
|
||
|
||
# Create UI-specific configuration
|
||
cat > /etc/nginx/sites-available/hesabix-ui.conf <<NGINX
|
||
# Frontend (Flutter Web)
|
||
server {
|
||
listen 80;
|
||
server_name ${UI_DOMAIN};
|
||
|
||
root /var/www/${UI_DOMAIN};
|
||
index index.html;
|
||
|
||
# Security headers
|
||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header X-XSS-Protection "1; mode=block" always;
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
|
||
# Public share link: /p/{code} → backend (307 redirect)
|
||
location /p/ {
|
||
proxy_pass http://127.0.0.1:8000/p/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 30;
|
||
proxy_connect_timeout 10;
|
||
proxy_send_timeout 30;
|
||
}
|
||
|
||
# Invoice share link: /i/{code} → backend (307 redirect)
|
||
location /i/ {
|
||
proxy_pass http://127.0.0.1:8000/i/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 30;
|
||
proxy_connect_timeout 10;
|
||
proxy_send_timeout 30;
|
||
}
|
||
|
||
# Proxy /api/ and /ws/ to backend (when API and UI share same domain)
|
||
# AI chat SSE: stream chunks immediately (no proxy buffering)
|
||
location ^~ /api/v1/ai/chat/ {
|
||
proxy_pass http://127.0.0.1:8000/api/v1/ai/chat/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_read_timeout 600;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 600;
|
||
client_max_body_size 1g;
|
||
proxy_buffering off;
|
||
proxy_request_buffering off;
|
||
proxy_cache off;
|
||
gzip off;
|
||
add_header X-Accel-Buffering no always;
|
||
}
|
||
|
||
location /api/ {
|
||
proxy_pass http://127.0.0.1:8000/api/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_read_timeout 300;
|
||
proxy_send_timeout 300;
|
||
client_max_body_size 1g;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
}
|
||
location /ws/ {
|
||
proxy_pass http://127.0.0.1:8000/ws/;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_read_timeout 86400;
|
||
proxy_send_timeout 86400;
|
||
}
|
||
|
||
# Same UI+API domain: without this block, /docs hits try_files and returns Flutter index.html.
|
||
# Only /assets/swagger/ and doc logo proxy to backend to avoid clashing with Flutter web assets.
|
||
location ^~ /docs {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location = /openapi.json {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location ^~ /redoc {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location ^~ /assets/swagger/ {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
location = /assets/logo-blue.png {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
}
|
||
|
||
location ^~ /assets/icons/ {
|
||
proxy_pass http://127.0.0.1:8000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_set_header X-Forwarded-Host \$host;
|
||
proxy_set_header X-Forwarded-Port \$server_port;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
}
|
||
|
||
# SPA: public paths (share links, etc.) → index.html
|
||
location /public/ {
|
||
try_files \$uri \$uri/ /index.html;
|
||
}
|
||
|
||
# Build version (always fresh; client uses this to detect updates)
|
||
location = /version.json {
|
||
add_header Cache-Control "no-store" always;
|
||
expires off;
|
||
try_files \$uri =404;
|
||
}
|
||
|
||
# Service Worker must not be locked with one-year immutable cache
|
||
location = /flutter_service_worker.js {
|
||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||
expires off;
|
||
try_files \$uri =404;
|
||
}
|
||
|
||
# Flutter entry JS filenames are usually stable; one-year immutable cache runs old JS after deploy
|
||
location = /flutter_bootstrap.js {
|
||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||
expires off;
|
||
try_files \$uri =404;
|
||
}
|
||
|
||
location = /main.dart.js {
|
||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||
expires off;
|
||
try_files \$uri =404;
|
||
}
|
||
|
||
location / {
|
||
try_files \$uri \$uri/ /index.html;
|
||
}
|
||
|
||
# Cache static assets
|
||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||
expires 1y;
|
||
add_header Cache-Control "public, immutable";
|
||
}
|
||
|
||
gzip on;
|
||
gzip_vary on;
|
||
gzip_min_length 1024;
|
||
gzip_types text/plain text/css application/javascript application/json image/svg+xml text/xml application/xml application/xml+rss;
|
||
gzip_comp_level 6;
|
||
}
|
||
NGINX
|
||
|
||
ln -sf /etc/nginx/sites-available/hesabix-ui.conf /etc/nginx/sites-enabled/hesabix-ui.conf
|
||
|
||
# Test nginx configuration
|
||
if ! nginx -t; then
|
||
echo "$CROSS_MARK Error in Nginx configuration"
|
||
exit 1
|
||
fi
|
||
|
||
# Reload nginx
|
||
if systemctl reload nginx; then
|
||
echo "$CHECK_MARK Nginx configured and reloaded for UI."
|
||
else
|
||
echo "$CROSS_MARK Error reloading Nginx"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
configure_api_ssl() {
|
||
echo ">> Configuring SSL for API domain..."
|
||
|
||
: "${ENABLE_API_SSL:=}"
|
||
if [[ -z "${ENABLE_API_SSL}" ]]; then
|
||
echo
|
||
read -rp "Enable SSL/TLS for API domain (${API_DOMAIN}) with Let's Encrypt? (y/N): " ENABLE_API_SSL
|
||
ENABLE_API_SSL=${ENABLE_API_SSL:-N}
|
||
fi
|
||
|
||
if [[ "${ENABLE_API_SSL}" =~ ^[Yy]$ ]]; then
|
||
echo ">> Installing and configuring TLS for API..."
|
||
|
||
if ! command -v certbot >/dev/null 2>&1; then
|
||
if ! apt-get install -y certbot python3-certbot-nginx; then
|
||
log_error "Failed to install certbot (API SSL)."
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
: "${CERTBOT_EMAIL:=admin@${API_DOMAIN}}"
|
||
if [[ -z "${CERTBOT_EMAIL}" ]] || [[ "${CERTBOT_EMAIL}" == "admin@${API_DOMAIN}" ]]; then
|
||
read -rp "Email for SSL certificate (default: admin@${API_DOMAIN}): " input_email
|
||
CERTBOT_EMAIL=${input_email:-admin@${API_DOMAIN}}
|
||
fi
|
||
|
||
if [[ ! "${CERTBOT_EMAIL}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||
echo "$WARNING_MARK Invalid email format. Using default: admin@${API_DOMAIN}"
|
||
CERTBOT_EMAIL="admin@${API_DOMAIN}"
|
||
fi
|
||
|
||
if certbot --nginx -d "${API_DOMAIN}" --redirect --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" 2>&1; then
|
||
echo "$CHECK_MARK SSL/TLS enabled for API domain."
|
||
systemctl enable certbot.timer 2>/dev/null || true
|
||
systemctl start certbot.timer 2>/dev/null || true
|
||
echo "$CHECK_MARK SSL certificate auto-renewal enabled."
|
||
return 0
|
||
else
|
||
echo "$WARNING_MARK Error issuing SSL certificate for API domain. You can run manually later:"
|
||
echo " certbot --nginx -d ${API_DOMAIN}"
|
||
return 1
|
||
fi
|
||
else
|
||
echo "SSL/TLS skipped for API domain; you can run certbot later:"
|
||
echo " certbot --nginx -d ${API_DOMAIN}"
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
configure_ui_ssl() {
|
||
echo ">> Configuring SSL for UI domain..."
|
||
|
||
: "${ENABLE_UI_SSL:=}"
|
||
if [[ -z "${ENABLE_UI_SSL}" ]]; then
|
||
echo
|
||
read -rp "Enable SSL/TLS for UI domain (${UI_DOMAIN}) with Let's Encrypt? (y/N): " ENABLE_UI_SSL
|
||
ENABLE_UI_SSL=${ENABLE_UI_SSL:-N}
|
||
fi
|
||
|
||
if [[ "${ENABLE_UI_SSL}" =~ ^[Yy]$ ]]; then
|
||
echo ">> Installing and configuring TLS for UI..."
|
||
|
||
if ! command -v certbot >/dev/null 2>&1; then
|
||
if ! apt-get install -y certbot python3-certbot-nginx; then
|
||
log_error "Failed to install certbot (UI SSL)."
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
: "${CERTBOT_EMAIL:=admin@${UI_DOMAIN}}"
|
||
if [[ -z "${CERTBOT_EMAIL}" ]] || [[ "${CERTBOT_EMAIL}" == "admin@${UI_DOMAIN}" ]]; then
|
||
read -rp "Email for SSL certificate (default: admin@${UI_DOMAIN}): " input_email
|
||
CERTBOT_EMAIL=${input_email:-admin@${UI_DOMAIN}}
|
||
fi
|
||
|
||
if [[ ! "${CERTBOT_EMAIL}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||
echo "$WARNING_MARK Invalid email format. Using default: admin@${UI_DOMAIN}"
|
||
CERTBOT_EMAIL="admin@${UI_DOMAIN}"
|
||
fi
|
||
|
||
if certbot --nginx -d "${UI_DOMAIN}" --redirect --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" 2>&1; then
|
||
echo "$CHECK_MARK SSL/TLS enabled for UI domain."
|
||
if ! systemctl is-enabled certbot.timer >/dev/null 2>&1; then
|
||
systemctl enable certbot.timer 2>/dev/null || true
|
||
systemctl start certbot.timer 2>/dev/null || true
|
||
echo "$CHECK_MARK SSL certificate auto-renewal enabled."
|
||
fi
|
||
return 0
|
||
else
|
||
echo "$WARNING_MARK Error issuing SSL certificate for UI domain. You can run manually later:"
|
||
echo " certbot --nginx -d ${UI_DOMAIN}"
|
||
return 1
|
||
fi
|
||
else
|
||
echo "SSL/TLS skipped for UI domain; you can run certbot later:"
|
||
echo " certbot --nginx -d ${UI_DOMAIN}"
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
install_pgadmin4() {
|
||
echo ">> Installing pgAdmin4 (Nginx + Gunicorn, no Apache)..."
|
||
|
||
local pgadmin_venv="/opt/pgadmin4/venv"
|
||
local pgadmin_data="/var/lib/pgadmin4"
|
||
local pgadmin_log="/var/log/pgadmin4"
|
||
|
||
if [[ -x "${pgadmin_venv}/bin/gunicorn" ]] && [[ -d "${pgadmin_venv}/lib" ]]; then
|
||
echo "$CHECK_MARK pgAdmin4 (Gunicorn) is already installed. Skipping..."
|
||
return 0
|
||
fi
|
||
|
||
configure_pip_hesabix_mirror
|
||
set_pip_mirror_env
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
apt-get install -y -qq python3-venv python3-pip libpq-dev >/dev/null 2>&1
|
||
|
||
mkdir -p /opt/pgadmin4
|
||
if [[ ! -d "${pgadmin_venv}" ]]; then
|
||
echo "Creating Python venv for pgAdmin4..."
|
||
python3 -m venv "${pgadmin_venv}"
|
||
log_info "Installing pgAdmin4 from PyPI: ${PIP_INDEX_URL}"
|
||
if "${pgadmin_venv}/bin/pip" install -U pip -q && "${pgadmin_venv}/bin/pip" install pgadmin4 gunicorn -q; then
|
||
log_success "pgAdmin4 installed from ${PIP_INDEX_URL}"
|
||
else
|
||
log_error "Failed to install pgAdmin4 from ${PIP_INDEX_URL}. Check mirror reachability or PIP_INDEX_URL override."
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
mkdir -p "${pgadmin_data}"/{sessions,storage,azurecredentialcache,kerberoscache}
|
||
mkdir -p "${pgadmin_log}"
|
||
chown -R www-data:www-data "${pgadmin_data}" "${pgadmin_log}"
|
||
|
||
local pgadmin_site
|
||
pgadmin_site=$("${pgadmin_venv}/bin/python3" -c "import pgadmin4; print(pgadmin4.__path__[0])" 2>/dev/null)
|
||
if [[ -z "$pgadmin_site" || ! -d "$pgadmin_site" ]]; then
|
||
echo "$CROSS_MARK Could not find pgAdmin4 package path."
|
||
return 1
|
||
fi
|
||
|
||
# config_local.py for server mode
|
||
cat > "${pgadmin_site}/config_local.py" <<CONFIG
|
||
SERVER_MODE = True
|
||
LOG_FILE = '${pgadmin_log}/pgadmin4.log'
|
||
SQLITE_PATH = '${pgadmin_data}/pgadmin4.db'
|
||
SESSION_DB_PATH = '${pgadmin_data}/sessions'
|
||
STORAGE_DIR = '${pgadmin_data}/storage'
|
||
AZURE_CREDENTIAL_CACHE_DIR = '${pgadmin_data}/azurecredentialcache'
|
||
KERBEROS_CCACHE_DIR = '${pgadmin_data}/kerberoscache'
|
||
CONFIG
|
||
chown www-data:www-data "${pgadmin_site}/config_local.py"
|
||
|
||
# Setup database (must run as www-data)
|
||
if [[ ! -f "${pgadmin_data}/pgadmin4.db" ]]; then
|
||
echo "Initializing pgAdmin4 database..."
|
||
sudo -u www-data "${pgadmin_venv}/bin/python3" "${pgadmin_site}/setup.py" setup-db 2>/dev/null || true
|
||
fi
|
||
|
||
# Systemd service: Gunicorn (single worker as required by pgAdmin for connection affinity)
|
||
cat > /etc/systemd/system/pgadmin4.service <<UNIT
|
||
[Unit]
|
||
Description=pgAdmin4 (Gunicorn)
|
||
After=network.target postgresql.service
|
||
Wants=postgresql.service
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=www-data
|
||
Group=www-data
|
||
WorkingDirectory=${pgadmin_site}
|
||
Environment="PATH=${pgadmin_venv}/bin"
|
||
ExecStart=${pgadmin_venv}/bin/gunicorn --bind 127.0.0.1:5050 --workers 1 --threads 25 --timeout 300 pgAdmin4:app
|
||
Restart=always
|
||
RestartSec=5
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
UNIT
|
||
|
||
systemctl daemon-reload
|
||
|
||
# Stop if already running so the new unit definition is used on next start
|
||
if check_service pgadmin4; then
|
||
systemctl stop pgadmin4
|
||
fi
|
||
|
||
systemctl enable pgadmin4
|
||
systemctl start pgadmin4
|
||
|
||
if [[ -n "${PGADMIN4_EMAIL}" ]] && [[ -n "${PGADMIN4_PASSWORD}" ]]; then
|
||
echo " First login: open https://${PGADMIN4_DOMAIN:-pgadmin.example.com} and register with email: ${PGADMIN4_EMAIL}"
|
||
fi
|
||
echo "$CHECK_MARK pgAdmin4 installed (Gunicorn on 127.0.0.1:5050). Nginx will proxy to it."
|
||
}
|
||
|
||
configure_nginx_pgadmin4() {
|
||
echo ">> Configuring Nginx for pgAdmin4..."
|
||
|
||
if [[ -z "${PGADMIN4_DOMAIN}" ]]; then
|
||
echo "$WARNING_MARK pgAdmin4 domain not set. Skipping Nginx configuration."
|
||
return 1
|
||
fi
|
||
|
||
if ! command -v nginx >/dev/null 2>&1; then
|
||
echo "$CROSS_MARK Nginx is not installed"
|
||
return 1
|
||
fi
|
||
|
||
if ! systemctl is-active --quiet pgadmin4; then
|
||
echo "Starting pgAdmin4 (Gunicorn) service..."
|
||
systemctl start pgadmin4
|
||
systemctl enable pgadmin4
|
||
fi
|
||
|
||
# Nginx reverse proxy to Gunicorn (no Apache)
|
||
cat > /etc/nginx/sites-available/pgadmin4.conf <<NGINX
|
||
# pgAdmin4 (reverse proxy to Gunicorn on 127.0.0.1:5050)
|
||
server {
|
||
listen 80;
|
||
server_name ${PGADMIN4_DOMAIN};
|
||
|
||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header X-XSS-Protection "1; mode=block" always;
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
|
||
location / {
|
||
proxy_pass http://127.0.0.1:5050;
|
||
proxy_set_header Host \$host;
|
||
proxy_set_header X-Real-IP \$remote_addr;
|
||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||
proxy_redirect off;
|
||
proxy_read_timeout 300;
|
||
proxy_connect_timeout 60;
|
||
proxy_send_timeout 300;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade \$http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
}
|
||
}
|
||
NGINX
|
||
|
||
ln -sf /etc/nginx/sites-available/pgadmin4.conf /etc/nginx/sites-enabled/pgadmin4.conf
|
||
|
||
# Test nginx configuration
|
||
if ! nginx -t; then
|
||
echo "$CROSS_MARK Error in Nginx configuration"
|
||
return 1
|
||
fi
|
||
|
||
# Reload nginx
|
||
if systemctl reload nginx; then
|
||
echo "$CHECK_MARK Nginx configured and reloaded for pgAdmin4."
|
||
else
|
||
echo "$CROSS_MARK Error reloading Nginx"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
configure_pgadmin4_ssl() {
|
||
echo ">> Configuring SSL for pgAdmin4 domain..."
|
||
|
||
if [[ -z "${PGADMIN4_DOMAIN}" ]]; then
|
||
echo "$WARNING_MARK pgAdmin4 domain not set. Skipping SSL configuration."
|
||
return 0
|
||
fi
|
||
|
||
: "${ENABLE_PGADMIN4_SSL:=}"
|
||
if [[ -z "${ENABLE_PGADMIN4_SSL}" ]]; then
|
||
echo
|
||
read -rp "Enable SSL/TLS for pgAdmin4 domain (${PGADMIN4_DOMAIN}) with Let's Encrypt? (y/N): " ENABLE_PGADMIN4_SSL
|
||
ENABLE_PGADMIN4_SSL=${ENABLE_PGADMIN4_SSL:-N}
|
||
fi
|
||
|
||
if [[ "${ENABLE_PGADMIN4_SSL}" =~ ^[Yy]$ ]]; then
|
||
echo ">> Installing and configuring TLS for pgAdmin4..."
|
||
|
||
if ! command -v certbot >/dev/null 2>&1; then
|
||
if ! apt-get install -y certbot python3-certbot-nginx; then
|
||
log_error "Failed to install certbot (pgAdmin4 SSL)."
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
: "${CERTBOT_EMAIL:=${PGADMIN4_EMAIL:-admin@${PGADMIN4_DOMAIN}}}"
|
||
if [[ -z "${CERTBOT_EMAIL}" ]] || [[ "${CERTBOT_EMAIL}" == "admin@${PGADMIN4_DOMAIN}" ]]; then
|
||
read -rp "Email for SSL certificate (default: ${CERTBOT_EMAIL}): " input_email
|
||
CERTBOT_EMAIL=${input_email:-${CERTBOT_EMAIL}}
|
||
fi
|
||
|
||
if [[ ! "${CERTBOT_EMAIL}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
|
||
echo "$WARNING_MARK Invalid email format. Using default: ${PGADMIN4_EMAIL:-admin@${PGADMIN4_DOMAIN}}"
|
||
CERTBOT_EMAIL="${PGADMIN4_EMAIL:-admin@${PGADMIN4_DOMAIN}}"
|
||
fi
|
||
|
||
if certbot --nginx -d "${PGADMIN4_DOMAIN}" --redirect --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" 2>&1; then
|
||
echo "$CHECK_MARK SSL/TLS enabled for pgAdmin4 domain."
|
||
if ! systemctl is-enabled certbot.timer >/dev/null 2>&1; then
|
||
systemctl enable certbot.timer 2>/dev/null || true
|
||
systemctl start certbot.timer 2>/dev/null || true
|
||
echo "$CHECK_MARK SSL certificate auto-renewal enabled."
|
||
fi
|
||
return 0
|
||
else
|
||
echo "$WARNING_MARK Error issuing SSL certificate for pgAdmin4 domain. You can run manually later:"
|
||
echo " certbot --nginx -d ${PGADMIN4_DOMAIN}"
|
||
return 1
|
||
fi
|
||
else
|
||
echo "SSL/TLS skipped for pgAdmin4 domain; you can run certbot later:"
|
||
echo " certbot --nginx -d ${PGADMIN4_DOMAIN}"
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
main() {
|
||
if [[ $EUID -ne 0 ]]; then
|
||
echo "$CROSS_MARK Please run this script with root privileges (e.g. sudo bash deploy.sh)."
|
||
exit 1
|
||
fi
|
||
|
||
# Unattended apt: avoid needrestart restarting services at end of install (fwupd-refresh etc.
|
||
# often fail or hang on minimal/headless servers; unrelated to Hesabix). Override: NEEDRESTART_SUSPEND=0
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
export APT_LISTCHANGES_FRONTEND=none
|
||
export NEEDRESTART_SUSPEND="${NEEDRESTART_SUSPEND:-1}"
|
||
|
||
# Initialize log file
|
||
init_log_file
|
||
|
||
# Check OS compatibility (must be Debian/Ubuntu)
|
||
check_os_compatibility
|
||
|
||
# Minimum RAM check (before license prompt so user time is not wasted)
|
||
check_minimum_ram
|
||
|
||
# Show license information
|
||
show_license_info
|
||
|
||
# Require license acceptance
|
||
accept_license
|
||
|
||
echo
|
||
echo "=========================================="
|
||
echo " Hesabix Deployment Script"
|
||
echo "=========================================="
|
||
echo
|
||
log_info "Starting deployment process..."
|
||
log_info "Please provide the required information when prompted."
|
||
echo
|
||
|
||
# Check disk space
|
||
check_disk_space
|
||
|
||
# Check if port 8000 is in use (ss preferred; netstat optional)
|
||
if command -v ss >/dev/null 2>&1; then
|
||
if ss -tuln | grep -qE ':8000(\s|$)'; then
|
||
log_warning "Port 8000 is in use. You may need to stop the previous service."
|
||
fi
|
||
elif command -v netstat >/dev/null 2>&1; then
|
||
if netstat -tuln | grep -q ":8000 "; then
|
||
log_warning "Port 8000 is in use. You may need to stop the previous service."
|
||
fi
|
||
fi
|
||
|
||
# Load saved inputs from previous run (so re-run after failure doesn't require re-entering)
|
||
load_saved_deploy_vars
|
||
if [[ -f "${APP_ROOT}/.deploy_saved_vars" ]]; then
|
||
echo "Loaded saved defaults from a previous run (domains, branch, pgAdmin4, etc.)."
|
||
echo "To override: set environment variables (e.g. API_DOMAIN=api.example.com) or press Enter at each prompt to be asked again."
|
||
echo
|
||
fi
|
||
|
||
# Prompt user for configuration
|
||
prompt_vars
|
||
|
||
# Show configuration summary
|
||
show_config_summary
|
||
|
||
# Ask for final confirmation
|
||
confirm_installation
|
||
echo
|
||
|
||
# Reset state if requested
|
||
reset_deployment_state
|
||
echo
|
||
|
||
# Install prerequisites (skip if already done)
|
||
if ! check_step_completed "prereqs"; then
|
||
install_prereqs
|
||
mark_step_completed "prereqs"
|
||
else
|
||
echo "$CHECK_MARK Prerequisites already installed. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Clone repository (skip if already done; resume will retry from here if previous run failed after repo)
|
||
if ! check_step_completed "repo"; then
|
||
clone_repo
|
||
mark_step_completed "repo"
|
||
else
|
||
echo "$CHECK_MARK Repository already cloned/updated. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Setup database (idempotent)
|
||
if ! check_step_completed "db"; then
|
||
setup_db
|
||
mark_step_completed "db"
|
||
else
|
||
echo "$CHECK_MARK Database already configured. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Deploy backend (skip if already done; resume from here if previous run failed during/after backend)
|
||
if ! check_step_completed "backend"; then
|
||
deploy_backend
|
||
mark_step_completed "backend"
|
||
else
|
||
echo "$CHECK_MARK Backend already deployed. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Configure Nginx API (skip if already done)
|
||
if ! check_step_completed "nginx_api"; then
|
||
configure_nginx_api
|
||
mark_step_completed "nginx_api"
|
||
else
|
||
echo "$CHECK_MARK Nginx for API already configured. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Configure Nginx UI (skip if already done)
|
||
if ! check_step_completed "nginx_ui"; then
|
||
configure_nginx_ui
|
||
mark_step_completed "nginx_ui"
|
||
else
|
||
echo "$CHECK_MARK Nginx for UI already configured. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Configure SSL API (mark done only on success or explicit skip — not on certbot failure)
|
||
if ! check_step_completed "ssl_api"; then
|
||
if configure_api_ssl; then
|
||
mark_step_completed "ssl_api"
|
||
else
|
||
log_warning "API SSL step incomplete; re-run deploy.sh to retry Let's Encrypt for ${API_DOMAIN}."
|
||
fi
|
||
else
|
||
echo "$CHECK_MARK SSL for API already configured. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Build frontend after API TLS attempt so dart-define matches http vs https (see scripts/api_public_scheme.sh).
|
||
if ! check_step_completed "frontend"; then
|
||
install_flutter_and_build_frontend
|
||
mark_step_completed "frontend"
|
||
else
|
||
echo "$CHECK_MARK Frontend already built and deployed. Skipping..."
|
||
persist_flutter_path_in_profile_d
|
||
fi
|
||
echo
|
||
|
||
if ! check_step_completed "ssl_ui"; then
|
||
if configure_ui_ssl; then
|
||
mark_step_completed "ssl_ui"
|
||
else
|
||
log_warning "UI SSL step incomplete; re-run deploy.sh to retry Let's Encrypt for ${UI_DOMAIN}."
|
||
fi
|
||
else
|
||
echo "$CHECK_MARK SSL for UI already configured. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
# Install and configure pgAdmin4 (optional)
|
||
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]]; then
|
||
if ! check_step_completed "pgadmin4"; then
|
||
install_pgadmin4
|
||
mark_step_completed "pgadmin4"
|
||
else
|
||
echo "$CHECK_MARK pgAdmin4 already installed. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
if ! check_step_completed "nginx_pgadmin4"; then
|
||
configure_nginx_pgadmin4
|
||
mark_step_completed "nginx_pgadmin4"
|
||
else
|
||
echo "$CHECK_MARK Nginx for pgAdmin4 already configured. Skipping..."
|
||
fi
|
||
echo
|
||
|
||
if ! check_step_completed "ssl_pgadmin4"; then
|
||
if configure_pgadmin4_ssl; then
|
||
mark_step_completed "ssl_pgadmin4"
|
||
else
|
||
log_warning "pgAdmin4 SSL step incomplete; re-run deploy.sh to retry Let's Encrypt for ${PGADMIN4_DOMAIN}."
|
||
fi
|
||
else
|
||
echo "$CHECK_MARK SSL for pgAdmin4 already configured. Skipping..."
|
||
fi
|
||
echo
|
||
fi
|
||
|
||
# Save config and install hesabix command for future updates
|
||
install_hesabix_command
|
||
echo
|
||
|
||
# Clear state file on successful completion
|
||
clear_deployment_state
|
||
|
||
echo "=========================================="
|
||
log_success "Deployment completed!"
|
||
echo "=========================================="
|
||
echo
|
||
log_info "Access URLs:"
|
||
local _api_s _ui_s
|
||
_api_s="$(hesabix_resolve_api_public_scheme)"
|
||
if [[ -d "/etc/letsencrypt/live/${UI_DOMAIN}" ]]; then _ui_s=https; else _ui_s=http; fi
|
||
echo " API: ${_api_s}://${API_DOMAIN}/api/v1/health"
|
||
echo " UI: ${_ui_s}://${UI_DOMAIN}/"
|
||
if [[ "${INSTALL_PGADMIN4}" =~ ^[Yy]$ ]] && [[ -n "${PGADMIN4_DOMAIN}" ]]; then
|
||
if [[ -d "/etc/letsencrypt/live/${PGADMIN4_DOMAIN}" ]]; then
|
||
echo " pgAdmin4: https://${PGADMIN4_DOMAIN}/"
|
||
else
|
||
echo " pgAdmin4: http://${PGADMIN4_DOMAIN}/"
|
||
fi
|
||
fi
|
||
if [[ "${INSTALL_VOICE:-N}" =~ ^[Yy]$ ]]; then
|
||
echo " Voice WS: wss://${API_DOMAIN}/ws/ai/voice (local STT/TTS)"
|
||
fi
|
||
echo
|
||
log_info "Service management:"
|
||
echo " systemctl status hesabix-api # API status"
|
||
echo " systemctl restart hesabix-api # Restart API"
|
||
echo " journalctl -u hesabix-api -f # View API logs"
|
||
echo " systemctl status hesabix-rq-worker # RQ Worker status"
|
||
echo " systemctl status hesabix-notification-moderation # Notification Moderation Worker status"
|
||
echo " systemctl status nginx # Nginx status"
|
||
echo
|
||
log_info "Deployment log file:"
|
||
echo " ${LOG_FILE}"
|
||
echo
|
||
log_info "To update (pull, migrate, rebuild, restart):"
|
||
echo " sudo hesabix -update"
|
||
echo " sudo hesabix -update -source https://source.hesabix.ir/hesabix/arc.git # override repo"
|
||
echo " sudo hesabix -cli reload # update /usr/local/bin/hesabix from repo"
|
||
echo
|
||
log_info "To change domains or SSL after install:"
|
||
echo " sudo hesabix -domains show"
|
||
echo " sudo hesabix -domains set --api api.example.com --ui app.example.com --ssl"
|
||
echo " sudo hesabix -domains apply # refresh Nginx from .deploy_env"
|
||
echo " sudo hesabix -ssl status"
|
||
echo " sudo hesabix -ssl enable --all"
|
||
echo
|
||
log_info "To start/stop/restart all Hesabix app services (systemd):"
|
||
echo " sudo hesabix -services start"
|
||
echo " sudo hesabix -services stop"
|
||
echo " sudo hesabix -services restart"
|
||
echo " sudo hesabix -services status # alias: show"
|
||
echo " # includes hesabix-api-media (Softphone Media Edge) when installed"
|
||
echo
|
||
log_info "To re-run deploy (resume from last step or full upgrade):"
|
||
echo " BRANCH=${BRANCH} API_DOMAIN=${API_DOMAIN} UI_DOMAIN=${UI_DOMAIN} sudo -E bash deploy.sh"
|
||
echo " (Use RESET_STATE=y to run all steps from the beginning)"
|
||
echo
|
||
log_info "Database password is stored in:"
|
||
echo " ${APP_ROOT}/.db_password"
|
||
echo
|
||
}
|
||
|
||
main "$@"
|
||
|
||
|