gendesign/ops/backup-forgejo.sh
bot-backend 7d98a674c9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(ops): лёгкий Postgres на Beget под forgejo и glitchtip
Переезд ломает сам Forgejo. Базы forgejo (265 МБ) и glitchtip (729 МБ)
физически лежат внутри gendesign-postgres-1, который уезжает целиком вместе
с 15 ГБ Site Finder. В момент переезда легли бы веб, git по HTTP и Actions —
то есть CI, из которого мы деплоим, ровно тогда, когда он нужнее всего.
Отдельно обидно, что Forgejo заявлен компенсацией риска «всё в одном
аккаунте Selectel»: падая вместе с переездом, он эту роль не выполняет.

Владелец выбрал вариант А — отдельный лёгкий кластер на Beget. PostGIS тут
не нужен, обе схемы обычные реляционные: postgres:16-alpine против
postgis/postgis:16-3.4 ради одного гигабайта данных.

Мерж и деплой ничего не меняют. Сервис под профилем "infra", которого нет
ни в одном окружении на Beget, поэтому docker compose его не видит;
GLITCHTIP_DB_HOST имеет дефолт postgres, то есть строка подключения
GlitchTip рендерится байт-в-байт как сегодня. Переключение — правка одной
переменной на VM после переноса данных, откат — правка обратно.

Первая версия этого не держала: сервис стоял в профиле glitchtip, который
на Beget уже активен, и мерж поднял бы кластер с пустым INFRA_PG_PASSWORD
в вечный restart-loop при зелёном деплое. Поймано ревью.

Healthcheck проверяет не приём соединений, а наличие ОБЕИХ баз. Иначе
отравленный том — когда bootstrap-скрипт упал уже после инициализации
PGDATA и больше никогда не запустится — выглядел бы здоровым, а
обнаружился бы в ночь переезда как «role forgejo does not exist».

backup-forgejo.sh больше не хардкодит контейнер: ищет тот, где база forgejo
реально есть, и при двух кандидатах останавливается с exit 1. Это ровно
окно миграции, когда база лежит в обоих: молча выбрать один означает с
вероятностью 1/2 бэкапить труп, а порог MIN_DB_DUMP_BYTES протухший дамп
не поймает — он нормального размера.

ops/split-infra-postgres.sh — перенос: dry-run по умолчанию, снимок числа
строк после останова писателей, восстановление целевой ролью (суперюзер
забрал бы таблицы себе и выдал permission denied уже после переключения),
сверка построчных карт из pg_class. Ревью нашло два сценария бесшумной
потери, оба закрыты: писатель с несовпавшим именем больше не пропускается
молча (имя контейнера Forgejo — догадка, его compose вне репозитория), а
--force-restore отказывается работать, если переключение уже сделано.
Дампы под umask 077 и chmod 600: в дампе forgejo хеши паролей и токены.

Проверено на живом Docker, не по YAML: матрица профилей через compose
config, healthcheck исполнен в postgres:16-alpine (обе базы → 0, после DROP
одной → 1), bootstrap реально завёл роли и базы, пароль со спецсимволами
логинится, в docker logs его нет.

Refs #3061, #3057, #3075, #2989, #2203
2026-08-24 19:28:59 +03:00

368 lines
24 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Backup for self-hosted Forgejo (git.gendsgn.ru) — repos + DB, off-box to S3
# under its OWN, more-restricted key (#2203).
#
# Forgejo is deployed OUTSIDE this repo's checkout, per
# infra/Forgejo_Migration_BotServer_To_Beget_2026-05-16.md (vault):
# - compose dir on the VM: /home/gendesign/forgejo/
# - repo data: /home/gendesign/forgejo/data/forgejo/git/repositories/
# - DB: NOT a dedicated container — a separate `forgejo` user+database
# inside a SHARED postgres container. Снимается всегда одинаково,
# `pg_dump -U forgejo forgejo` через `docker exec`; меняется только то,
# В КАКОМ контейнере эта база лежит, и вот это уже не константа (см. ниже).
#
# ДВА ДОМА У БАЗЫ forgejo (#3061). Переезд продукта Beget → Selectel, окно
# 30.08.2026: контейнер `gendesign-postgres-1` уезжает целиком вместе с томом
# postgres_data, а Forgejo остаётся на Beget. Отсюда две фазы:
# ДО переезда — база forgejo живёт в общем кластере основного приложения,
# контейнер `gendesign-postgres-1`;
# ПОСЛЕ — в лёгком кластере остающейся инфраструктуры, контейнер
# `gendesign-infra-postgres` (сервис infra-postgres в
# docker-compose.prod.yml).
#
# ПОЧЕМУ ИМЯ КОНТЕЙНЕРА НЕ ЗАШИТО КОНСТАНТОЙ. Раньше здесь стояло
# `PG_CONTAINER="${PG_CONTAINER:-gendesign-postgres-1}"`. Беда в том, что после
# переноса старый контейнер не исчезает мгновенно — он ещё какое-то время жив и
# хранит СВОЮ копию базы forgejo, которая с момента переноса протухает с каждым
# коммитом и каждым PR. Константа означала бы, что бэкап продолжает исправно
# дампить именно её. И ни одна проверка ниже этого не поймала бы: дамп непустой,
# маркер "-- PostgreSQL database dump complete" на месте, размер заведомо выше
# MIN_DB_DUMP_BYTES — все пороги про ОБЪЁМ и ЦЕЛОСТНОСТЬ, ни один не про
# СВЕЖЕСТЬ. На выходе — здоровый с виду бэкап, который бэкапом не является, и
# узнаём мы об этом ровно в момент восстановления. Поэтому контейнер выбирается
# по ФАКТУ наличия ДАННЫХ среди запущенных (guard 2 ниже), а неоднозначность —
# база нашлась сразу в двух — это не повод угадать, а повод остановиться.
#
# Two artifacts per run:
# 1. DB dump — `pg_dump --no-owner --clean --if-exists` (repos/issues/PRs/
# users/settings/Actions config — everything except the git data itself).
# 2. Repo bundle — a tar.gz of the bare-repo tree under FORGEJO_REPOS_DIR.
# This is functionally a "bundle of repositories": bare repos already
# ARE just refs+objects, byte-identical to what `git bundle` would
# capture, and tar-ing the whole tree in one shot is far more robust
# than enumerating individual repos (new repos need zero script changes,
# nothing to keep in sync). LFS objects/attachments/avatars under
# FORGEJO_DATA_DIR are OUT of scope for this pass — repo code + full DB
# (which has LFS pointers, not the blobs) covers the "backup the code"
# ask; note this gap in the PR if larger asset backup is wanted later.
#
# Off-box S3 upload is MANDATORY here, not optional like ops/backup.sh —
# the whole point of this script is getting Forgejo's code off the VM it
# lives on, so with no S3 creds configured this refuses to run rather than
# silently producing a local-only "backup" that doesn't meet that bar.
#
# The S3 key here is a SEPARATE, narrower-scoped service user than the one
# ops/backup.sh and backup-tradein-db.sh use (`gendsgn-backup-writer`, which
# can write anywhere in the bucket root) — this one can ONLY PutObject under
# s3://gendsgn-backups/forgejo/*, nothing else, so a compromised Forgejo host
# can't touch the main/tradein DB backups sitting in the same bucket. See the
# PR description for the exact bucket policy JSON (mirrors the existing
# gendsgn-backup-writer policy pattern — vault meta/00_credentials.md
# "Selectel S3 — бэкап-бакет gendsgn-backups").
#
# THE KEY DOES NOT EXIST YET (as of #2203) — a human needs to create the
# service user + policy in the Selectel panel first. Until then this script
# is expected to fail loudly and exit non-zero; that is the correct,
# intentional behaviour, not a bug.
#
# Missed-run detection (#2203): same sentinel/staleness-check pattern as
# ops/backup.sh — see ops/check-backup-staleness.sh for the alerting side.
#
# Usage (cron — `bash <path>`, not a bare path, same "+x can go missing on a
# raw deploy reset" reasoning as the other two backup scripts, #71):
# 15 4 * * * bash /opt/gendesign/ops/backup-forgejo.sh >> /var/log/gendesign-backup-forgejo.log 2>&1
#
# Config file (root-owned, chmod 600, NOT in git) — see
# ops/gendesign-backup-forgejo.default.example for the full list:
# FORGEJO_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
# FORGEJO_S3_BUCKET=gendsgn-backups
# FORGEJO_S3_PREFIX=forgejo/
# FORGEJO_S3_ACCESS_KEY=...
# FORGEJO_S3_SECRET_KEY=...
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib-backup.sh
source "$SCRIPT_DIR/lib-backup.sh"
# --- config (env-overridable) ---
FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}"
FORGEJO_DATA_DIR="${FORGEJO_DATA_DIR:-${FORGEJO_DIR}/data/forgejo}"
FORGEJO_REPOS_DIR="${FORGEJO_REPOS_DIR:-${FORGEJO_DATA_DIR}/git/repositories}"
# Контейнер с базой Forgejo. ПО УМОЛЧАНИЮ ПУСТО — имени здесь больше нет,
# оно определяется автоматически в guard 2 (обоснование — в шапке). Явное
# значение из окружения или из ${FORGEJO_BACKUP_ENV_FILE} выигрывает и берётся
# как есть, без всяких проверок: это ручной override, и тот, кто его выставил,
# знает про свою инсталляцию больше, чем эвристика.
PG_CONTAINER="${PG_CONTAINER:-}"
# Где искать, когда PG_CONTAINER не задан. Список, а не одно имя: у базы forgejo
# два законных дома (#3061), и в окно переезда оба существуют одновременно.
# Переопределяется целиком — если имена контейнеров когда-нибудь снова
# поменяются, это правка одной строки в конфиге на VM, а не правка скрипта.
PG_CONTAINER_CANDIDATES="${PG_CONTAINER_CANDIDATES:-gendesign-postgres-1 gendesign-infra-postgres}"
FORGEJO_DB_USER="${FORGEJO_DB_USER:-forgejo}"
FORGEJO_DB_NAME="${FORGEJO_DB_NAME:-forgejo}"
LOCAL_BACKUP_DIR="${LOCAL_BACKUP_DIR:-/opt/gendesign/backups/forgejo}"
KEEP="${KEEP:-7}"
MIN_DB_DUMP_BYTES="${MIN_DB_DUMP_BYTES:-2048}" # forgejo DB is small (config/issues/PRs, no git blobs)
MIN_REPOS_BUNDLE_BYTES="${MIN_REPOS_BUNDLE_BYTES:-10240}"
SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}"
FORGEJO_BACKUP_ENV_FILE="${FORGEJO_BACKUP_ENV_FILE:-/etc/default/gendesign-backup-forgejo}"
# shellcheck source=/dev/null
[[ -f "$FORGEJO_BACKUP_ENV_FILE" ]] && source "$FORGEJO_BACKUP_ENV_FILE"
FORGEJO_S3_ENDPOINT="${FORGEJO_S3_ENDPOINT:-}"
FORGEJO_S3_BUCKET="${FORGEJO_S3_BUCKET:-}"
FORGEJO_S3_PREFIX="${FORGEJO_S3_PREFIX:-forgejo/}"
FORGEJO_S3_ACCESS_KEY="${FORGEJO_S3_ACCESS_KEY:-}"
FORGEJO_S3_SECRET_KEY="${FORGEJO_S3_SECRET_KEY:-}"
# --- guard 0: S3 creds MUST be configured — this backup's whole point is
# off-box storage under a narrow-scoped key. No creds = refuse to run,
# loudly, rather than silently producing a local-only "backup" (unlike
# ops/backup.sh, where local-only is an accepted fallback). ---
if [[ -z "$FORGEJO_S3_ENDPOINT" || -z "$FORGEJO_S3_BUCKET" || -z "$FORGEJO_S3_ACCESS_KEY" || -z "$FORGEJO_S3_SECRET_KEY" ]]; then
log "ERROR: Forgejo S3 backup is NOT CONFIGURED."
log "ERROR: missing one or more of FORGEJO_S3_ENDPOINT / FORGEJO_S3_BUCKET / FORGEJO_S3_ACCESS_KEY / FORGEJO_S3_SECRET_KEY."
log "ERROR: create the narrow-scoped 'gendsgn-backup-forgejo-writer' S3 service user (see PR for the exact bucket policy)"
log "ERROR: and fill them into ${FORGEJO_BACKUP_ENV_FILE} (see ops/gendesign-backup-forgejo.default.example)."
log "ERROR: refusing to produce a local-only backup — that does not meet this script's off-box requirement."
exit 1
fi
if [[ ! -d "$FORGEJO_REPOS_DIR" ]]; then
log "ERROR: FORGEJO_REPOS_DIR does not exist -> $FORGEJO_REPOS_DIR"
log "ERROR: Forgejo's on-disk layout may differ from the documented default — override FORGEJO_REPOS_DIR."
exit 1
fi
# --- guard 2: в каком контейнере лежит ЖИВАЯ база forgejo (#3061) ---
# Полное обоснование «почему не константа» — в шапке файла. Здесь суть: имя
# контейнера меняется в момент переезда, а ошибка выбора не диагностируется по
# самому дампу — он выглядит здоровым и в старом, и в новом кластере.
#
# Критерий кандидата: контейнер ЗАПУЩЕН и в базе ${FORGEJO_DB_NAME} внутри него
# есть ТАБЛИЦЫ. Именно таблицы, а не сам факт существования базы — и это
# принципиально. Лёгкий кластер поднимается ПУСТЫМ на обычном деплое, за дни до
# ручного переноса данных, и его initdb-скрипт
# (ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh) сразу заводит роль
# forgejo и ПУСТУЮ базу forgejo. Проверяй мы «база существует» — в тот же вечер
# кандидатов стало бы двое, автоопределение упёрлось бы в неоднозначность и
# ночной бэкап покраснел бы ещё до того, как хоть один байт куда-то переехал.
# А это прямо нарушает главное требование переезда: мерж и деплой сами по себе
# не меняют ничего. С проверкой «есть таблицы» пустая заготовка кандидатом не
# считается, и неоднозначность возникает ровно тогда, когда она настоящая:
# дамп залит, данные есть в обоих кластерах, и решение действительно за
# человеком.
#
# Проверяем ровно тем же доступом, которым потом снимается дамп: psql под ролью
# ${FORGEJO_DB_USER} в базу ${FORGEJO_DB_NAME} через `docker exec` (внутри
# контейнера это unix-сокет, на нём в официальном образе стоит trust, пароль не
# нужен — на этом же держится и pg_dump ниже). Так проверка не расходится с
# делом: до чего не достучалась она, из того и pg_dump ничего не снимет.
if [[ -n "$PG_CONTAINER" ]]; then
log "PG_CONTAINER задан явно -> ${PG_CONTAINER}; автоопределение пропущено."
else
log "PG_CONTAINER не задан — ищу контейнер с непустой базой ${FORGEJO_DB_NAME} среди запущенных: ${PG_CONTAINER_CANDIDATES}"
pg_running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)"
pg_found=()
# Разбиение по пробелам здесь НАМЕРЕННОЕ: PG_CONTAINER_CANDIDATES — это
# список имён, кавычки превратили бы его в одно имя с пробелами.
# shellcheck disable=SC2086
for pg_candidate in $PG_CONTAINER_CANDIDATES; do
if ! grep -qxF -- "$pg_candidate" <<< "$pg_running"; then
log " ${pg_candidate}: не запущен — пропускаю"
continue
fi
# Пустой вывод = до базы не достучались вовсе (нет такой базы, нет роли,
# кластер не отвечает); `|| pg_tables=""` обязателен — под pipefail
# неудача docker exec иначе уронила бы весь скрипт прямо здесь, вместо
# внятного разбора кандидатов ниже. tr -dc отрезает перевод строки и
# любой мусор, чтобы (( )) получило заведомо число.
pg_tables="$(docker exec "$pg_candidate" psql -U "$FORGEJO_DB_USER" -d "$FORGEJO_DB_NAME" -tAc \
"SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema')" \
2>/dev/null | tr -dc '0-9')" || pg_tables=""
if [[ -z "$pg_tables" ]]; then
log " ${pg_candidate}: запущен, но база ${FORGEJO_DB_NAME} под ролью ${FORGEJO_DB_USER} недоступна — пропускаю"
elif (( pg_tables == 0 )); then
log " ${pg_candidate}: запущен, база ${FORGEJO_DB_NAME} есть, но ПУСТА (0 таблиц) — заготовка под миграцию, пропускаю"
else
log " ${pg_candidate}: запущен, в базе ${FORGEJO_DB_NAME} таблиц: ${pg_tables} — кандидат"
pg_found+=( "$pg_candidate" )
fi
done
if (( ${#pg_found[@]} == 0 )); then
log "ERROR: не нашёл ни одного запущенного контейнера с НЕПУСТОЙ базой ${FORGEJO_DB_NAME}." >&2
log "ERROR: искал среди: ${PG_CONTAINER_CANDIDATES}" >&2
log "ERROR: критерий: контейнер в \`docker ps\` И \`psql -U ${FORGEJO_DB_USER} -d ${FORGEJO_DB_NAME}\` видит в нём хотя бы одну таблицу." >&2
log "ERROR: строки выше показывают, на чём отсеялся каждый кандидат — 'не запущен' / 'недоступна' / 'ПУСТА'." >&2
log "ERROR: сейчас запущены: $(echo "$pg_running" | tr -s '[:space:]' ' ')" >&2
log "ERROR: что делать: если контейнер называется иначе — PG_CONTAINER=<имя> в ${FORGEJO_BACKUP_ENV_FILE};" >&2
log "ERROR: если имён стало больше — PG_CONTAINER_CANDIDATES='<имя1> <имя2>' там же. Бэкап НЕ снят." >&2
exit 1
fi
if (( ${#pg_found[@]} > 1 )); then
log "ERROR: непустая база ${FORGEJO_DB_NAME} нашлась сразу в нескольких запущенных контейнерах: ${pg_found[*]}" >&2
log "ERROR: это ровно окно переезда (#3061): старый кластер ещё жив и держит УСТАРЕВШУЮ копию базы," >&2
log "ERROR: новый — рабочую, и по дампу их не отличить (оба непустые, оба проходят пороги размера)." >&2
log "ERROR: выбрать наугад значит с вероятностью 1/2 бэкапить труп, поэтому останавливаюсь." >&2
log "ERROR: что делать: PG_CONTAINER=<боевой контейнер> в ${FORGEJO_BACKUP_ENV_FILE}," >&2
log "ERROR: а после гашения старого кластера эту строку убрать — автоопределение снова однозначно. Бэкап НЕ снят." >&2
exit 1
fi
PG_CONTAINER="${pg_found[0]}"
log "Автоопределение -> ${PG_CONTAINER}: единственный запущенный контейнер с непустой базой ${FORGEJO_DB_NAME}."
fi
mkdir -p "$LOCAL_BACKUP_DIR"
ts=$(date -u +'%Y%m%d_%H%M%S')
db_out="${LOCAL_BACKUP_DIR}/forgejo-db_${ts}.sql.gz"
repos_out="${LOCAL_BACKUP_DIR}/forgejo-repos_${ts}.tar.gz"
# --- 1. DB dump ---
log "Dumping Forgejo DB (${FORGEJO_DB_NAME} as ${FORGEJO_DB_USER}, container ${PG_CONTAINER}) -> ${db_out}"
docker exec "$PG_CONTAINER" pg_dump -U "$FORGEJO_DB_USER" -d "$FORGEJO_DB_NAME" --no-owner --clean --if-exists \
| gzip -9 > "$db_out"
if [[ ! -s "$db_out" ]]; then
log "ERROR: DB dump is empty -> $db_out — removing, keeping previous good backups." >&2
rm -f "$db_out"
exit 1
fi
db_bytes=$(wc -c < "$db_out" | tr -d ' ')
if (( db_bytes < MIN_DB_DUMP_BYTES )); then
log "ERROR: DB dump only ${db_bytes} bytes (< ${MIN_DB_DUMP_BYTES} floor) — likely a failed dump." >&2
rm -f "$db_out"
exit 1
fi
if ! verify_dump_integrity "$db_out" "-- PostgreSQL database dump complete" "forgejo DB dump"; then
log "Removing suspect DB dump, NOT pruning older good backups." >&2
rm -f "$db_out"
exit 1
fi
log "DB dump OK: ${db_out} ($(du -h "$db_out" | cut -f1), ${db_bytes} bytes)"
# --- 2. repo bundle (tar.gz of the bare-repo tree) ---
log "Bundling Forgejo repos from ${FORGEJO_REPOS_DIR} -> ${repos_out}"
tar -czf "$repos_out" -C "$(dirname "$FORGEJO_REPOS_DIR")" "$(basename "$FORGEJO_REPOS_DIR")"
if [[ ! -s "$repos_out" ]]; then
log "ERROR: repo bundle is empty -> $repos_out — removing." >&2
rm -f "$repos_out"
exit 1
fi
repos_bytes=$(wc -c < "$repos_out" | tr -d ' ')
if (( repos_bytes < MIN_REPOS_BUNDLE_BYTES )); then
log "ERROR: repo bundle only ${repos_bytes} bytes (< ${MIN_REPOS_BUNDLE_BYTES} floor) — likely empty/failed FORGEJO_REPOS_DIR." >&2
rm -f "$repos_out"
exit 1
fi
# tar integrity: a bundle truncated mid-write (disk full, killed process)
# fails `tar -t` even when the gzip framing looks superficially OK — same
# "don't trust non-empty + big-enough alone" discipline as verify_dump_integrity.
if ! tar -tzf "$repos_out" >/dev/null 2>&1; then
log "ERROR: repo bundle failed tar integrity check -> $repos_out (truncated archive?)" >&2
rm -f "$repos_out"
exit 1
fi
log "Repo bundle OK: ${repos_out} ($(du -h "$repos_out" | cut -f1), ${repos_bytes} bytes)"
# --- 3. config bundle (#2203 follow-up) ---
# ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ АРТЕФАКТ. Бэкап забирал БД и голые репозитории — то есть
# СОДЕРЖИМОЕ Forgejo, но не то, ЧЕМ оно поднимается. Компоуз-файлы Forgejo и
# раннеров живут в /home/gendesign/forgejo/, вне репозитория (проверено:
# `git rev-parse` там отвечает "not a git repository"), их не трогает ни один
# деплой и не покрывал ни один бэкап.
#
# Практический смысл: при потере хоста БД и репозитории восстанавливаются, а
# КОНФИГУРАЦИЯ — нет. Пришлось бы вручную воссоздавать docker-compose.yml и
# заново регистрировать три раннера. Файлы крошечные (~5 КБ на оба), так что
# цена включения нулевая, а цена отсутствия — ручная реконструкция в худший
# момент.
#
# Косвенное подтверждение, что каталог правится руками и без истории: рядом
# лежат runner-compose.yml.bak, .bak.1779049805, .bak.before-runner3 — три
# самодельных снимка вместо version control.
#
# ЧТО НАМЕРЕННО НЕ ВКЛЮЧЕНО:
# .env (режим 600) и runner*/data/ с регистрационными токенами — секреты.
# Они остаются ручным off-box пунктом владельца, тем же, что .env.runtime
# в приёмке #2203. Раннеры при восстановлении перерегистрируются из UI за
# пару минут; компоуз-файл руками не восстановишь.
config_out="${LOCAL_BACKUP_DIR}/forgejo-config_${ts}.tar.gz"
log "Bundling Forgejo compose config from ${FORGEJO_DIR} -> ${config_out}"
# -C + явные имена, а не путь целиком: в архиве оказываются только те файлы,
# что перечислены, без каталога data/ (он огромен и уже покрыт repo-бандлом).
if ! tar -czf "$config_out" -C "$FORGEJO_DIR" \
--exclude='.env' \
$(cd "$FORGEJO_DIR" && ls *.yml 2>/dev/null) 2>/dev/null; then
log "WARN: не удалось собрать config-бандл — продолжаю без него" >&2
rm -f "$config_out"
config_out=""
fi
if [[ -n "$config_out" ]]; then
# Порог намеренно низкий: файлы и правда маленькие. Проверяем не «объём», а
# «архив собрался и читается» — те же две проверки, что у repo-бандла.
config_bytes=$(wc -c < "$config_out" | tr -d ' ')
if (( config_bytes < 200 )) || ! tar -tzf "$config_out" >/dev/null 2>&1; then
log "WARN: config-бандл подозрителен (${config_bytes} байт / не читается) — не выгружаю" >&2
rm -f "$config_out"
config_out=""
else
log "Config bundle OK: ${config_out} (${config_bytes} байт, файлов: $(tar -tzf "$config_out" | wc -l))"
fi
fi
# --- 4. upload to S3 (mandatory — see guard 0 above) ---
for f in "$db_out" "$repos_out" ${config_out:+"$config_out"}; do
key="${FORGEJO_S3_PREFIX%/}/$(basename "$f")"
log "Uploading to s3://${FORGEJO_S3_BUCKET}/${key}"
# Same AWS_CA_BUNDLE fix as ops/backup.sh / backup-tradein-db.sh — aws-cli
# v2's own baked-in CA bundle is missing the root Selectel's cert chains
# to; point it at the container's system store instead.
docker run --rm \
-e AWS_ACCESS_KEY_ID="$FORGEJO_S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$FORGEJO_S3_SECRET_KEY" \
-e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
-v "$LOCAL_BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$FORGEJO_S3_ENDPOINT" \
s3 cp --no-progress "/backup/$(basename "$f")" "s3://${FORGEJO_S3_BUCKET}/${key}"
done
log "S3 upload OK"
# --- 4. local retention ---
# shellcheck disable=SC2012
ls -1t "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
# shellcheck disable=SC2012
ls -1t "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz 2>/dev/null \
| tail -n +"$((KEEP + 1))" \
| xargs -r rm -f
shopt -s nullglob
remaining_db=( "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz )
remaining_repos=( "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz )
shopt -u nullglob
log "Forgejo backup done. Local copies retained: ${#remaining_db[@]} db + ${#remaining_repos[@]} repo bundles (KEEP=${KEEP})."
# --- missed-run detection (#2203): mark success ONLY after everything above
# (including the mandatory S3 upload) has passed. ---
write_sentinel "$SENTINEL_FILE"