#!/usr/bin/env bash # Общие функции установщика и служебных скриптов glchat. # Подключается через `source`; сам по себе ничего не выполняет. set -euo pipefail GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}" GLCHAT_INSTALL_LOG="${GLCHAT_INSTALL_LOG:-${GLCHAT_ETC_DIR}/install.log}" GLCHAT_ENV_FILE="${GLCHAT_ENV_FILE:-${GLCHAT_ETC_DIR}/.env}" GLCHAT_GREEN=$'\033[32m' GLCHAT_YELLOW=$'\033[33m' GLCHAT_RED=$'\033[31m' GLCHAT_BOLD=$'\033[1m' GLCHAT_RESET=$'\033[0m' log() { local line line="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" printf '%s\n' "$line" if [ -n "${GLCHAT_INSTALL_LOG:-}" ] && [ -w "$(dirname "$GLCHAT_INSTALL_LOG")" ] 2>/dev/null; then printf '%s\n' "$line" >>"$GLCHAT_INSTALL_LOG" fi } log_file_only() { if [ -n "${GLCHAT_INSTALL_LOG:-}" ]; then mkdir -p "$(dirname "$GLCHAT_INSTALL_LOG")" 2>/dev/null || true printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$GLCHAT_INSTALL_LOG" 2>/dev/null || true fi } info() { printf '%s==>%s %s\n' "$GLCHAT_BOLD" "$GLCHAT_RESET" "$*"; log "INFO $*"; } ok() { printf '%s ok%s %s\n' "$GLCHAT_GREEN" "$GLCHAT_RESET" "$*"; log "OK $*"; } warn() { printf '%swarn%s %s\n' "$GLCHAT_YELLOW" "$GLCHAT_RESET" "$*" >&2; log "WARN $*"; } die() { printf '%serr %s %s\n' "$GLCHAT_RED" "$GLCHAT_RESET" "$*" >&2 log "ERROR $*" exit 1 } need_root() { # В режиме --dry-run изменения не применяются, поэтому root не требуется: # это позволяет проверять установщик на машине разработчика. if [ "${DRY_RUN:-0}" = "1" ]; then return 0 fi if [ "$(id -u)" -ne 0 ]; then die "запустите установщик от root: sudo bash deploy/install.sh" fi } have() { command -v "$1" >/dev/null 2>&1; } require_cmd() { for cmd in "$@"; do have "$cmd" || die "не найдена обязательная команда: $cmd" done } confirm() { local prompt="$1" default="${2:-no}" reply if [ "${GLCHAT_ASSUME_YES:-0}" = "1" ]; then return 0 fi if [ ! -t 0 ]; then [ "$default" = "yes" ] && return 0 || return 1 fi read -r -p "$prompt " reply || true reply="${reply:-$default}" case "$reply" in y | Y | yes | YES | да | Да) return 0 ;; *) return 1 ;; esac } prompt_value() { local prompt="$1" default="${2:-}" reply if [ ! -t 0 ]; then printf '%s' "$default" return 0 fi if [ -n "$default" ]; then read -r -p "$prompt [$default]: " reply || true printf '%s' "${reply:-$default}" else read -r -p "$prompt: " reply || true printf '%s' "$reply" fi } prompt_secret() { local prompt="$1" first second if [ ! -t 0 ]; then die "$prompt: требуется интерактивный ввод или флаг --password" fi while true; do read -r -s -p "$prompt: " first || true printf '\n' >&2 read -r -s -p "повторите пароль: " second || true printf '\n' >&2 if [ "$first" != "$second" ]; then warn "пароли не совпадают, попробуйте снова" continue fi if [ "${#first}" -lt 8 ]; then warn "пароль короче 8 символов" continue fi printf '%s' "$first" return 0 done } rand_hex() { openssl rand -hex "${1:-32}"; } rand_base64() { openssl rand -base64 "${1:-32}" | tr -d '\n=' | tr '+/' '-_'; } json_escape() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" printf '%s' "$value" } # --- профили производительности (AGENT.md 10.10) ------------------------- profile_values() { local tier="$1" case "$tier" in lite) PROFILE_SQLITE_CACHE_MB=8 PROFILE_SQLITE_MMAP_MB=128 PROFILE_SQLITE_READ_POOL=2 PROFILE_GOMAXPROCS=1 PROFILE_LRU_ENTRIES=2000 PROFILE_LIVEKIT_MAX_PARTICIPANTS=10 PROFILE_LIVEKIT_MAX_VIDEO=4 PROFILE_LIVEKIT_SIMULCAST=0 PROFILE_UPLOAD_MB=25 PROFILE_APP_LIMIT=384m PROFILE_LIVEKIT_LIMIT=640m PROFILE_SWAP_GB=2 PROFILE_BACKUP_KEEP=7 PROFILE_SHARE_SOFT=1440p/30 PROFILE_SHARE_MAX=4K/60 ;; standard) PROFILE_SQLITE_CACHE_MB=16 PROFILE_SQLITE_MMAP_MB=256 PROFILE_SQLITE_READ_POOL=4 PROFILE_GOMAXPROCS=2 PROFILE_LRU_ENTRIES=10000 PROFILE_LIVEKIT_MAX_PARTICIPANTS=25 PROFILE_LIVEKIT_MAX_VIDEO=8 PROFILE_LIVEKIT_SIMULCAST=2 PROFILE_UPLOAD_MB=50 PROFILE_APP_LIMIT=768m PROFILE_LIVEKIT_LIMIT=1536m PROFILE_SWAP_GB=2 PROFILE_BACKUP_KEEP=14 PROFILE_SHARE_SOFT=1440p/30 PROFILE_SHARE_MAX=4K/60 ;; plus) PROFILE_SQLITE_CACHE_MB=32 PROFILE_SQLITE_MMAP_MB=512 PROFILE_SQLITE_READ_POOL=8 PROFILE_GOMAXPROCS=4 PROFILE_LRU_ENTRIES=50000 PROFILE_LIVEKIT_MAX_PARTICIPANTS=50 PROFILE_LIVEKIT_MAX_VIDEO=16 PROFILE_LIVEKIT_SIMULCAST=3 PROFILE_UPLOAD_MB=100 PROFILE_APP_LIMIT=1536m PROFILE_LIVEKIT_LIMIT=3g PROFILE_SWAP_GB=2 PROFILE_BACKUP_KEEP=30 PROFILE_SHARE_SOFT=4K/30 PROFILE_SHARE_MAX=4K/60 ;; pro) PROFILE_SQLITE_CACHE_MB=64 PROFILE_SQLITE_MMAP_MB=1024 PROFILE_SQLITE_READ_POOL="$(detect_cpus)" PROFILE_GOMAXPROCS="$(detect_cpus)" PROFILE_LRU_ENTRIES=200000 PROFILE_LIVEKIT_MAX_PARTICIPANTS=100 PROFILE_LIVEKIT_MAX_VIDEO=32 PROFILE_LIVEKIT_SIMULCAST=3 PROFILE_UPLOAD_MB=250 PROFILE_APP_LIMIT=4g PROFILE_LIVEKIT_LIMIT=6g PROFILE_SWAP_GB=0 PROFILE_BACKUP_KEEP=60 PROFILE_SHARE_SOFT=4K/60 PROFILE_SHARE_MAX=4K/60 ;; none) PROFILE_SQLITE_CACHE_MB=8 PROFILE_SQLITE_MMAP_MB=128 PROFILE_SQLITE_READ_POOL=2 PROFILE_GOMAXPROCS=0 PROFILE_LRU_ENTRIES=2000 PROFILE_LIVEKIT_MAX_PARTICIPANTS=10 PROFILE_LIVEKIT_MAX_VIDEO=4 PROFILE_LIVEKIT_SIMULCAST=0 PROFILE_UPLOAD_MB=25 PROFILE_APP_LIMIT=512m PROFILE_LIVEKIT_LIMIT=768m PROFILE_SWAP_GB=0 PROFILE_BACKUP_KEEP=7 PROFILE_SHARE_SOFT=1080p/30 PROFILE_SHARE_MAX=4K/60 ;; *) die "неизвестный профиль: $tier" ;; esac } detect_profile() { local cpus ram_mb cpus="$(detect_cpus)" if [ -r /proc/meminfo ]; then ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" else ram_mb=4096 fi if [ "$cpus" -le 1 ] && [ "$ram_mb" -lt 3072 ]; then printf 'lite' elif [ "$cpus" -le 2 ] || [ "$ram_mb" -lt 5120 ]; then printf 'standard' elif [ "$cpus" -le 4 ] || [ "$ram_mb" -lt 9216 ]; then printf 'plus' else printf 'pro' fi } # --- рендеринг шаблонов --------------------------------------------------- # render_template src dst KEY=value... # # Значения передаются через окружение, а не подставляются в аргументы: это # исключает разбор значений с пробелами и переносами строк и делает рендер # предсказуемым (bash ${var//pat/repl} на многострочных значениях и variadic # аргументах ведёт себя неочевидно). Если gettext-base (envsubst) недоступен, # используется эквивалентная замена средствами bash. render_template() { local src="$1" dst="$2" shift 2 # Нечётное число аргументов означает неполный вызов: пишем в журнал # установки, но продолжаем — реальная проверка ниже (незаполненные # плейсхолдеры в результате рендера). if [ $(( $# % 2 )) -ne 0 ]; then log_file_only "render_template($(basename "$src")): нечётное число аргументов: $#" fi # export "$pair" разбирает KEY=value сам; export "${pair}" трактует всю # пару как имя переменной — из-за этого значения не попадали в окружение. local pair for pair in "$@"; do export "$pair" done local content if command -v envsubst >/dev/null 2>&1; then content="$(envsubst <"$src")" else content="$(cat "$src")" for pair in "$@"; do local key="${pair%%=*}" value="${pair#*=}" content="${content//"{{${key}}}"/$value}" done fi if printf '%s' "$content" | grep -q '{{[A-Z_]\{2,\}}}'; then local leftover leftover="$(printf '%s' "$content" | grep -o '{{[A-Z_]\{2,\}}}' | sort -u | tr '\n' ' ')" warn "в шаблоне $(basename "$src") остались незаполненные плейсхолдеры: $leftover" fi printf '%s\n' "$content" >"$dst" } env_get() { local key="$1" file="${2:-$GLCHAT_ENV_FILE}" [ -f "$file" ] || return 1 grep -E "^${key}=" "$file" | tail -1 | cut -d= -f2- } env_set() { local key="$1" value="$2" file="${3:-$GLCHAT_ENV_FILE}" if grep -qE "^${key}=" "$file" 2>/dev/null; then local tmp tmp="$(mktemp)" awk -v k="$key" -v v="$value" 'BEGIN { FS = "=" } $1 == k { print k "=" v; next } { print }' "$file" >"$tmp" cat "$tmp" >"$file" rm -f "$tmp" else printf '%s=%s\n' "$key" "$value" >>"$file" fi } # --- общие проверки ------------------------------------------------------- check_os() { if [ ! -r /etc/os-release ]; then # macOS и другие не-Linux системы: допустимо только для --dry-run. if [ "${DRY_RUN:-0}" = "1" ]; then warn "не Linux (нет /etc/os-release): проверка ОС пропущена в режиме --dry-run" return 0 fi die "не удалось прочитать /etc/os-release" fi # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}" in ubuntu) case "${VERSION_ID:-}" in 24.04 | 26.04) ok "ОС: Ubuntu ${VERSION_ID} (${PRETTY_NAME})" ;; *) if [ "${GLCHAT_FORCE_OS:-0}" = "1" ]; then warn "Ubuntu ${VERSION_ID:-?} не в списке проверенных; продолжаем из-за --force-os" else die "поддерживаются Ubuntu 24.04 LTS и 26.04 LTS, обнаружено: ${PRETTY_NAME:-unknown}. Используйте --force-os для продолжения" fi ;; esac ;; debian) warn "Debian ${VERSION_ID:-?}: установка не проверялась, продолжаем (пакеты Debian-совместимы)" ;; *) die "поддерживается Ubuntu 24.04/26.04 LTS, обнаружено: ${PRETTY_NAME:-unknown}" ;; esac } check_resources() { local cpus ram_mb disk_mb cpus="$(detect_cpus)" if [ -r /proc/meminfo ]; then ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" else ram_mb=0 fi disk_mb="$(df -Pm / | awk 'NR==2 {print $4}')" info "ресурсы: ${cpus} vCPU, ${ram_mb} MB RAM, ${disk_mb} MB свободно на /" if [ "$ram_mb" -gt 0 ] && [ "$ram_mb" -lt 1800 ]; then warn "меньше 2 GB RAM: профиль lite может не уложиться в бюджет (AGENT.md 9.1)" fi if [ "$disk_mb" -lt 8000 ]; then warn "меньше 8 GB свободного места: сборка образа и бэкапы могут не поместиться" fi } detect_cpus() { if command -v nproc >/dev/null 2>&1; then nproc return fi if command -v sysctl >/dev/null 2>&1; then sysctl -n hw.ncpu 2>/dev/null && return fi printf '2' } check_command() { have "$1" && ok "найдено: $1" || warn "отсутствует: $1 (будет установлено)" }