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
Переезд ломает сам 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
368 lines
24 KiB
Bash
Executable file
368 lines
24 KiB
Bash
Executable file
#!/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"
|