diff --git a/.env.example b/.env.example index 2bf8465..898d984 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,6 @@ WEBHOOK_SECRET= # подпись вебхуков LIVEKIT_API_KEY= # ключ LiveKit (фаза 3) LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3) AGE_RECIPIENT= # публичный age-ключ для шифрования бэкапов +BACKUP_KEEP_DAYS= # сколько дней хранить бэкапы (профиль задаёт значение) +BACKUP_OFFSITE_TARGET= # необязательно: rsync-цель (user@host:/path) или rclone:remote:path +BACKUP_OFFSITE_KEEP_DAYS= # необязательно: чистить копии на приёмнике старше N дней (rclone) diff --git a/Makefile b/Makefile index 33afe91..d56cc95 100644 --- a/Makefile +++ b/Makefile @@ -193,6 +193,17 @@ install-dry-run: ## Проверить установщик без измене install-test: ## Прогон install.sh в чистом контейнере Ubuntu 24.04 bash scripts/test-install.sh +.PHONY: profiles-test +profiles-test: ## Проверить таблицу профилей производительности (AGENT.md 10.10) + bash scripts/test-profiles.sh + +.PHONY: backup-test +backup-test: ## Проверить бэкап и offsite-выгрузку на синтетическом инстансе + bash scripts/test-backup.sh + +.PHONY: ops-test +ops-test: profiles-test backup-test ## Проверки эксплуатационных скриптов + .PHONY: clean clean: ## Удалить артефакты сборки rm -rf $(BUILD_DIR) diff --git a/deploy/backup.sh b/deploy/backup.sh index 1412d2e..4bb5117 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -9,6 +9,7 @@ BACKUP_DIR="${GLCHAT_DATA_DIR}/backups" KEEP_DAYS_DEFAULT=7 log() { printf '[backup] %s\n' "$*"; } +warn() { printf '[backup] внимание: %s\n' "$*" >&2; } die() { printf '[backup] ошибка: %s\n' "$*" >&2 exit 1 @@ -20,10 +21,35 @@ set -a && . "$ENV_FILE" && set +a KEEP_DAYS="${BACKUP_KEEP_DAYS:-$KEEP_DAYS_DEFAULT}" AGE_RECIPIENT="${AGE_RECIPIENT:-}" +# Выгрузка копии наружу: rsync-цель (user@host:/path) или `rclone:remote:path` +# (AGENT.md 10.8, опциональный offsite). Ошибка выгрузки не отменяет локальный +# бэкап: о ней пишем предупреждение. +OFFSITE_TARGET="${BACKUP_OFFSITE_TARGET:-}" +# Ротация на принимающей стороне — только по явной настройке: пусто значит +# «копим копии, чистим вручную». +OFFSITE_KEEP_DAYS="${BACKUP_OFFSITE_KEEP_DAYS:-}" +case "$OFFSITE_KEEP_DAYS" in + "") ;; + *[!0-9]*) + warn "BACKUP_OFFSITE_KEEP_DAYS='${OFFSITE_KEEP_DAYS}' не число — offsite-ротация выключена" + OFFSITE_KEEP_DAYS="" + ;; +esac STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +# Два запуска в одну секунду не должны затирать друг друга: имя с суффиксом. +SUFFIX="" +suffix_n=1 +while [ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz" ] || + [ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz.age" ]; do + SUFFIX="-${suffix_n}" + suffix_n=$((suffix_n + 1)) +done WORK_DIR="$(mktemp -d)" -ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}.tar.gz" +ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz" TARGET="${ARCHIVE}.age" +# ARCHIVE_OR_TARGET — готовый файл бэкапа (с шифрованием или без): его выгружает +# offsite-копирование. +ARCHIVE_OR_TARGET="" cleanup() { rm -rf "$WORK_DIR"; } trap cleanup EXIT @@ -71,13 +97,59 @@ if [ -n "$AGE_RECIPIENT" ]; then age -r "$AGE_RECIPIENT" -o "$TARGET" "$ARCHIVE" || die "age-шифрование не удалось" rm -f "$ARCHIVE" chmod 600 "$TARGET" + ARCHIVE_OR_TARGET="$TARGET" log "готово: ${TARGET} ($(du -h "$TARGET" | cut -f1))" else chmod 600 "$ARCHIVE" + ARCHIVE_OR_TARGET="$ARCHIVE" log "готово (БЕЗ шифрования, AGE_RECIPIENT не задан): ${ARCHIVE}" fi log "ротация: оставляем ${KEEP_DAYS} дн." find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' -mtime "+${KEEP_DAYS}" -print -delete || true +# Offsite идёт после локальной ротации: наружу уезжает уже актуальный набор. +# Неудача выгрузки не отменяет локальный бэкап — только предупреждение. +if [ -n "$OFFSITE_TARGET" ]; then + log "выгружаем копию: ${OFFSITE_TARGET}" + offsite_ok=0 + case "$OFFSITE_TARGET" in + rclone:*) + remote="${OFFSITE_TARGET#rclone:}" + if command -v rclone >/dev/null 2>&1; then + rclone copy "$ARCHIVE_OR_TARGET" "$remote" && offsite_ok=1 + else + warn "rclone не установлен: offsite-копия не сделана" + fi + ;; + *) + if command -v rsync >/dev/null 2>&1; then + # Цель — каталог на приёмнике; без --delete копии только копятся. + rsync -az --partial "$ARCHIVE_OR_TARGET" "${OFFSITE_TARGET%/}/" && offsite_ok=1 + else + warn "rsync не установлен: offsite-копия не сделана" + fi + ;; + esac + if [ "$offsite_ok" = "1" ]; then + log "offsite-копия отправлена" + else + warn "offsite-копия не отправлена (локальный бэкап на месте)" + fi + if [ -n "$OFFSITE_KEEP_DAYS" ]; then + case "$OFFSITE_TARGET" in + rclone:*) + if [ "$offsite_ok" = "1" ]; then + rclone delete --min-age "${OFFSITE_KEEP_DAYS}d" "${OFFSITE_TARGET#rclone:}" && + log "offsite-ротация: удалены копии старше ${OFFSITE_KEEP_DAYS} дн." || + warn "offsite-ротация не удалась" + fi + ;; + *) + warn "offsite-ротация по возрасту поддержана только для rclone — почистите приёмник вручную" + ;; + esac + fi +fi + log "бэкапов в каталоге: $(find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' | wc -l)" diff --git a/deploy/install.sh b/deploy/install.sh index b344266..c6c5175 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -37,6 +37,13 @@ if [ -z "$DEPLOY_DIR" ] || [ ! -f "${DEPLOY_DIR}/lib/common.sh" ]; then exec bash "${TMP_REPO}/deploy/install.sh" "$@" fi +# Путь установки задан явно? Спрашиваем до common.sh: он подставляет дефолт +# /opt/glchat, после чего отличить явное значение от дефолтного нельзя. +GLCHAT_ETC_DIR_EXPLICIT=0 +if [ -n "${GLCHAT_ETC_DIR:-}" ]; then + GLCHAT_ETC_DIR_EXPLICIT=1 +fi + # shellcheck source=lib/common.sh . "${DEPLOY_DIR}/lib/common.sh" @@ -111,9 +118,6 @@ GLCHAT_FORCE_OS="${GLCHAT_FORCE_OS:-0}" GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}" TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}" -if [ "$DRY_RUN" = "1" ] && [ -z "${GLCHAT_ETC_DIR:-}" ]; then - TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)" -fi GLCHAT_ETC_DIR="$TARGET_DIR" GLCHAT_DATA_DIR="${TARGET_DIR}/data" GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log" @@ -256,6 +260,18 @@ parse_args() { *) die "неизвестный аргумент: $1 (см. --help)" ;; esac done + + # Dry-run обязан быть безвредным: если /opt/glchat недоступен для записи и + # путь не задан явно — пишем .env и конфиги во временный каталог. + if [ "$DRY_RUN" = "1" ] && [ "$GLCHAT_ETC_DIR_EXPLICIT" = "0" ] && [ ! -w "$TARGET_DIR" ]; then + local unwritable="$TARGET_DIR" + TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)" + GLCHAT_ETC_DIR="$TARGET_DIR" + GLCHAT_DATA_DIR="${TARGET_DIR}/data" + GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log" + GLCHAT_ENV_FILE="${TARGET_DIR}/.env" + info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}" + fi } interactive_defaults() { @@ -768,7 +784,6 @@ LIVEKIT_API_KEY=@LIVEKIT_API_KEY@ LIVEKIT_API_SECRET=@LIVEKIT_API_SECRET@ TURN_RELAY_USER=@TURN_RELAY_USER@ TURN_RELAY_PASSWORD=@TURN_RELAY_PASSWORD@ -AGE_RECIPIENT=@AGE_RECIPIENT@ # --- профиль производительности --- SQLITE_READ_POOL=@SQLITE_READ_POOL@ @@ -793,6 +808,13 @@ SHARE_MAX_LIMIT=@SHARE_MAX@ BACKUP_KEEP_DAYS=@BACKUP_KEEP@ SWAP_SIZE_GB=@SWAP_SIZE@ +# --- бэкапы: offsite-выгрузка (опция, AGENT.md 10.8) --- +# Цель: rsync (user@host:/path) или rclone:remote:path; пусто — только локально. +BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@ +# Ротация на приёмнике (дни, только rclone); пусто — копим без ротации. +BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@ +AGE_RECIPIENT=@AGE_RECIPIENT@ + # --- функции --- REGISTRATION_ENABLED=@REGISTRATION_ENABLED@ ANTI_BOT_ENABLED=@ANTI_BOT_ENABLED@ @@ -861,6 +883,9 @@ LIVEKIT_API_KEY=${LIVEKIT_API_KEY} LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL} +# Offsite-бэкапы: цель и срок хранения задаются вручную после установки. +BACKUP_OFFSITE_TARGET=${BACKUP_OFFSITE_TARGET:-} +BACKUP_OFFSITE_KEEP_DAYS=${BACKUP_OFFSITE_KEEP_DAYS:-} AGE_RECIPIENT=${AGE_RECIPIENT:-} SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL} SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000 @@ -907,7 +932,8 @@ for line in open(pairs_path, encoding="utf-8"): for key, value in values.items(): template = template.replace("@" + key + "@", value) -leftovers = [t for t in __import__("re").findall(r"@[A-Z_]{2,}@", template)] +# В именах плейсхолдеров есть цифры (FAIL2BAN_ENABLED) — их тоже ловим. +leftovers = [t for t in __import__("re").findall(r"@[A-Z0-9_]{2,}@", template)] open(template_path, "w", encoding="utf-8").write(template) if leftovers: print("незаполненные плейсхолдеры: " + ", ".join(sorted(set(leftovers))), file=sys.stderr) diff --git a/scripts/test-backup.sh b/scripts/test-backup.sh new file mode 100755 index 0000000..5ead60d --- /dev/null +++ b/scripts/test-backup.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Проверка deploy/backup.sh без docker: синтетический инстанс во временном +# каталоге, локальный offsite-приёмник (rsync) и поведение при недоступной +# выгрузке. Запуск: make backup-test (AGENT.md 10.8, 11.3). +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/glchat-backup-test.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +checks=0 +errors=0 + +ok() { + checks=$((checks + 1)) + printf 'ok %s\n' "$*" +} + +fail() { + checks=$((checks + 1)) + errors=$((errors + 1)) + printf 'ОШИБКА %s\n' "$*" >&2 +} + +expect() { # expect <описание> <ожидаемое> <фактическое> + if [ "$2" = "$3" ]; then + ok "$1 = $3" + else + fail "$1: ожидалось '$2', получено '$3'" + fi +} + +# --- синтетический инстанс ------------------------------------------------- + +ETC="${WORK}/etc" +DATA="${ETC}/data" +mkdir -p "${DATA}/db" "${DATA}/files/attachments" "${ETC}/livekit" +sqlite3 "${DATA}/db/glchat.db" <<'SQL' +CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL); +CREATE TABLE messages (id INTEGER PRIMARY KEY, body TEXT NOT NULL); +INSERT INTO users (email) VALUES ('admin@example.test'), ('guest@example.test'); +INSERT INTO messages (body) VALUES ('привет'), ('как дела'); +SQL +printf 'файл вложения' >"${DATA}/files/attachments/a.txt" +printf 'service: glchat\n' >"${ETC}/compose.yaml" +printf 'port: 7880\n' >"${ETC}/livekit/livekit.yaml" +cat >"${ETC}/.env" <<'ENV' +DOMAIN=gl.example.test +APP_VERSION=v0.0.0-test +PROFILE=standard +BACKUP_KEEP_DAYS=30 +ENV + +run_backup() { # run_backup [keep-days] + BACKUP_OFFSITE_TARGET="$1" BACKUP_OFFSITE_KEEP_DAYS="${2:-}" \ + GLCHAT_ETC_DIR="$ETC" "${REPO_DIR}/deploy/backup.sh" +} + +# --- 1. без offsite -------------------------------------------------------- + +log_out="$(run_backup "")" +expect "локальный бэкап создан" "1" "$(find "${DATA}/backups" -maxdepth 1 -name 'glchat-*.tar.gz' | wc -l | tr -d ' ')" +case "$log_out" in + *"offsite"*) fail "offsite упомянут, хотя цель не задана" ;; + *) ok "без BACKUP_OFFSITE_TARGET выгрузки нет" ;; +esac + +# --- 2. offsite в локальный каталог (rsync) ------------------------------- + +OFFSITE="${WORK}/offsite" +mkdir -p "$OFFSITE" +run_backup "$OFFSITE" >/dev/null +expect "offsite-копия доехала" "1" "$(find "$OFFSITE" -maxdepth 1 -name 'glchat-*.tar.gz' | wc -l | tr -d ' ')" +local_newest="$(ls -t "${DATA}/backups"/glchat-*.tar.gz | head -1)" +local_base="$(basename "$local_newest")" +if [ -f "${OFFSITE}/${local_base}" ] && cmp -s "$local_newest" "${OFFSITE}/${local_base}"; then + ok "offsite-копия ${local_base} побайтово совпадает с локальной" +else + fail "offsite-копия ${local_base} отсутствует или отличается" +fi + +# --- 3. недоступный приёмник: предупреждение, но не авария ---------------- + +before="$(find "${DATA}/backups" -maxdepth 1 -name 'glchat-*.tar.gz' | wc -l | tr -d ' ')" +marker="${WORK}/marker" +touch "$marker" +if log_out="$(run_backup "no-such-host.invalid:/nonexistent" 2>&1)"; then + ok "недоступный offsite не роняет бэкап (код 0)" +else + fail "недоступный offsite привёл к ошибке" +fi +case "$log_out" in + *"offsite-копия не отправлена"*) ok "предупреждение о неудачной выгрузке есть" ;; + *) fail "нет предупреждения о неудачной выгрузке" ;; +esac +after="$(find "${DATA}/backups" -maxdepth 1 -name 'glchat-*.tar.gz' | wc -l | tr -d ' ')" +expect "локальные бэкапы продолжают создаваться" "$((before + 1))" "$after" + +# --- 4. нечисловой BACKUP_OFFSITE_KEEP_DAYS отключает ротацию ------------- + +if log_out="$(run_backup "$OFFSITE" "пять" 2>&1)"; then + ok "нечисловая offsite-ротация не роняет бэкап" +else + fail "нечисловая offsite-ротация привела к ошибке" +fi +case "$log_out" in + *"не число"*) ok "предупреждение о нечисловом значении есть" ;; + *) fail "нет предупреждения о нечисловом значении" ;; +esac + +# --- 5. AGE_RECIPIENT: шифрование при наличии age, внятная ошибка без него -- + +# AGE_RECIPIENT дописываем во временную копию .env и возвращаем исходный файл: +# дальше проверяется offsite через rclone, где шифрование только мешает. +cp "${ETC}/.env" "${ETC}/env.keep" +if command -v age >/dev/null 2>&1 && command -v age-keygen >/dev/null 2>&1; then + recipient="$(age-keygen 2>/dev/null | awk '/public key:/ {print $4}')" + printf 'AGE_RECIPIENT=%s\n' "$recipient" >>"${ETC}/.env" + plain_before="$(find "${DATA}/backups" -maxdepth 1 -name 'glchat-*.tar.gz' | wc -l | tr -d ' ')" + run_backup "" >/dev/null + expect "с AGE_RECIPIENT появился .age-файл" "1" "$(find "${DATA}/backups" -maxdepth 1 -name 'glchat-*.age' | wc -l | tr -d ' ')" + expect "новых незашифрованных архивов нет" "$plain_before" "$(find "${DATA}/backups" -maxdepth 1 -name 'glchat-*.tar.gz' | wc -l | tr -d ' ')" +else + printf 'AGE_RECIPIENT=age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq\n' >>"${ETC}/.env" + if run_backup "" >/dev/null 2>&1; then + fail "без установленного age бэкап не должен проходить молча" + else + ok "без age установщик бэкапа падает с ошибкой" + fi +fi + +mv "${ETC}/env.keep" "${ETC}/.env" + +# --- 6. rclone-приёмник: проверяем фактические команды rclone -------------- + +SHIM="${WORK}/bin" +mkdir -p "$SHIM" +cat >"${SHIM}/rclone" <<'SHIMEOF' +#!/usr/bin/env bash +printf 'rclone %s\n' "$*" >>"${RCLONE_LOG}" +exit 0 +SHIMEOF +chmod +x "${SHIM}/rclone" +rclone_log="${WORK}/rclone.log" +: >"$rclone_log" +( + export PATH="${SHIM}:${PATH}" RCLONE_LOG="$rclone_log" + run_backup "rclone:remote:glchat" 7 >/dev/null +) +if grep -q "^rclone copy .*glchat-.*\.tar\.gz remote:glchat$" "$rclone_log"; then + ok "rclone copy вызван с целью remote:glchat" +else + fail "rclone copy не вызван: $(cat "$rclone_log")" +fi +if grep -q "^rclone delete --min-age 7d remote:glchat$" "$rclone_log"; then + ok "rclone delete --min-age 7d вызван для ротации" +else + fail "offsite-ротация не вызвана: $(cat "$rclone_log")" +fi + +printf '\n=== бэкап/offsite: %d проверок, %d ошибок ===\n' "$checks" "$errors" +[ "$errors" -eq 0 ] diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 9c5e390..d81f563 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -99,6 +99,17 @@ if [ "$secrets_before" != "$secrets_after" ]; then fi echo "секреты сохранены при повторной установке: ok" +# Offsite-бэкапы: ключи должны присутствовать в .env сразу после установки +# (пустыми — цель задаёт владелец), иначе backup.sh не увидит настройку. +log "offsite-настройки в .env" +docker exec "$CONTAINER" bash -lc ' + set -e + for key in BACKUP_KEEP_DAYS BACKUP_OFFSITE_TARGET BACKUP_OFFSITE_KEEP_DAYS AGE_RECIPIENT; do + grep -q "^${key}=" /opt/glchat/.env || { echo "нет ${key} в .env" >&2; exit 1; } + echo "${key}: ok" + done +' + # Сборка образа внутри DinD невозможна (overlay поверх overlay), поэтому образ # собирается и проверяется отдельно на хосте (make docker-build + docker compose). log "проверки состояния установки" diff --git a/scripts/test-profiles.sh b/scripts/test-profiles.sh new file mode 100755 index 0000000..48e00e7 --- /dev/null +++ b/scripts/test-profiles.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# test-profiles.sh — проверка таблицы профилей производительности (AGENT.md 10.10). +# Значения из профиля обязаны совпадать со спецификацией: профиль меняет только +# лимиты и тюнинг, поэтому расхождение — дефект установщика. +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../deploy/lib/common.sh +source "${REPO_DIR}/deploy/lib/common.sh" + +failures=0 +checks=0 + +# expect <профиль> <параметр> <ожидаемое> +expect() { + local tier="$1" name="$2" want="$3" + local got="${!name:-}" + checks=$((checks + 1)) + if [ "$got" != "$want" ]; then + printf 'FAIL %s: %s = %s, ожидалось %s\n' "$tier" "$name" "$got" "$want" >&2 + failures=$((failures + 1)) + else + printf 'ok %s: %s = %s\n' "$tier" "$name" "$got" + fi +} + +check_lite() { + profile_values lite + expect lite PROFILE_SQLITE_CACHE_MB 8 + expect lite PROFILE_SQLITE_MMAP_MB 128 + expect lite PROFILE_SQLITE_READ_POOL 2 + expect lite PROFILE_GOMAXPROCS 1 + expect lite PROFILE_LRU_ENTRIES 2000 + expect lite PROFILE_LIVEKIT_MAX_PARTICIPANTS 10 + expect lite PROFILE_LIVEKIT_MAX_VIDEO 4 + expect lite PROFILE_LIVEKIT_SIMULCAST 0 + expect lite PROFILE_UPLOAD_MB 25 + expect lite PROFILE_APP_LIMIT 384m + expect lite PROFILE_LIVEKIT_LIMIT 640m + expect lite PROFILE_SWAP_GB 2 + expect lite PROFILE_BACKUP_KEEP 7 + expect lite PROFILE_SHARE_SOFT 1440p/30 +} + +check_standard() { + profile_values standard + expect standard PROFILE_SQLITE_CACHE_MB 16 + expect standard PROFILE_SQLITE_MMAP_MB 256 + expect standard PROFILE_SQLITE_READ_POOL 4 + expect standard PROFILE_GOMAXPROCS 2 + expect standard PROFILE_LRU_ENTRIES 10000 + expect standard PROFILE_LIVEKIT_MAX_PARTICIPANTS 25 + expect standard PROFILE_LIVEKIT_MAX_VIDEO 8 + expect standard PROFILE_LIVEKIT_SIMULCAST 2 + expect standard PROFILE_UPLOAD_MB 50 + expect standard PROFILE_APP_LIMIT 768m + expect standard PROFILE_LIVEKIT_LIMIT 1536m + expect standard PROFILE_SWAP_GB 2 + expect standard PROFILE_BACKUP_KEEP 14 + expect standard PROFILE_SHARE_SOFT 1440p/30 +} + +check_plus_and_pro() { + profile_values plus + expect plus PROFILE_SQLITE_CACHE_MB 32 + expect plus PROFILE_SQLITE_MMAP_MB 512 + expect plus PROFILE_UPLOAD_MB 100 + expect plus PROFILE_APP_LIMIT 1536m + expect plus PROFILE_LIVEKIT_LIMIT 3g + expect plus PROFILE_BACKUP_KEEP 30 + expect plus PROFILE_SHARE_SOFT 4K/30 + + profile_values pro + expect pro PROFILE_SQLITE_CACHE_MB 64 + expect pro PROFILE_SQLITE_MMAP_MB 1024 + expect pro PROFILE_UPLOAD_MB 250 + expect pro PROFILE_APP_LIMIT 4g + expect pro PROFILE_LIVEKIT_LIMIT 6g + expect pro PROFILE_BACKUP_KEEP 60 + expect pro PROFILE_SHARE_SOFT 4K/60 +} + +check_none() { + profile_values none + # none — только обязательный минимум: системные лимиты не трогаем. + expect none PROFILE_SWAP_GB 0 + expect none PROFILE_GOMAXPROCS 0 + expect none PROFILE_SHARE_SOFT 1080p/30 +} + +# Профиль меняет только лимиты: набор параметров у всех профилей одинаков, иначе +# переключение профиля оставило бы часть значений от прежнего. +check_same_keys() { + profile_values lite + local lite_keys + lite_keys="$(compgen -v PROFILE_ | sort | tr '\n' ' ')" + profile_values pro + local pro_keys + pro_keys="$(compgen -v PROFILE_ | sort | tr '\n' ' ')" + checks=$((checks + 1)) + if [ "$lite_keys" != "$pro_keys" ]; then + printf 'FAIL набор параметров профилей различается:\nlite: %s\npro: %s\n' "$lite_keys" "$pro_keys" >&2 + failures=$((failures + 1)) + else + printf 'ok набор параметров одинаков: %s\n' "$lite_keys" + fi +} + +check_lite +check_standard +check_plus_and_pro +check_none +check_same_keys + +printf '\n=== профили: %d проверок, %d ошибок ===\n' "$checks" "$failures" +[ "$failures" -eq 0 ] || exit 1