feat(ops): offsite-бэкапы и проверки профилей

- deploy/backup.sh: BACKUP_OFFSITE_TARGET (rsync или rclone:remote:path)
  выгружает готовый архив после локальной ротации; неудача выгрузки только
  предупреждает. Ротация на приёмнике — опционально
  (BACKUP_OFFSITE_KEEP_DAYS, rclone delete --min-age). Имя архива получает
  суффикс, если бэкап запущен дважды в одну секунду.
- deploy/install.sh: offsite-настройки в .env (BACKUP_OFFSITE_TARGET,
  BACKUP_OFFSITE_KEEP_DAYS) отдельным разделом; детектор незаполненных
  плейсхолдеров учитывает цифры в именах (FAIL2BAN_ENABLED); dry-run больше не
  требует прав на /opt/glchat — явность GLCHAT_ETC_DIR фиксируется до
  подключения common.sh, иначе ветка временного каталога была недостижима.
- scripts/test-profiles.sh: сверка таблицы профилей 10.10 и одинакового набора
  PROFILE_* (46 проверок), make profiles-test / ops-test.
- scripts/test-backup.sh: бэкап и offsite на синтетическом инстансе — локальный
  rsync-приёмник, шим rclone, недоступный хост, нечисловой срок (12 проверок),
  make backup-test.
- scripts/test-install.sh: проверка offsite-ключей в .env после установки.
This commit is contained in:
2026-09-22 18:44:50 +03:00
parent af03cfb063
commit 3151327f6e
7 changed files with 407 additions and 6 deletions
+162
View File
@@ -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 <offsite-цель> [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 ]
+11
View File
@@ -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 "проверки состояния установки"
+116
View File
@@ -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