Files
glchat/deploy/lib/common.sh
T

318 lines
12 KiB
Bash
Raw Normal View History

#!/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 (будет установлено)"
}