Files
2026-10-17 04:10:15 -03:00

414 lines
15 KiB
Bash

#!/usr/bin/env bash
#
# backup_create.sh
#
# Companion to setup-git-server.sh: creates ONE zip file that contains
# everything needed to bring a Gitea instance installed by that script back
# with backup_restore.sh:
#
# - the database (as a portable SQL dump, SQLite)
# - all repositories
# - data/ (LFS objects, attachments, avatars, packages, ...)
# - custom/ (templates, logos, ...)
# - app.ini (including SECRET_KEY / JWT secrets -- needed for 2FA etc.)
# - gitea-backup-info.txt (Gitea version, date, host -- lets the restore
# refuse a too-old Gitea *before* it touches anything)
#
# It uses Gitea's own `gitea dump`, so the archive is also a perfectly normal
# dump that `gitea doctor`/manual restores understand.
#
# By default Gitea is stopped while the dump runs, so the database and the
# repositories are consistent with each other, and started again right after
# (also if something goes wrong). Use --no-stop to back up a running server.
#
# Usage:
# sudo ./backup_create.sh [options]
#
# Run with --help for the full list of options.
set -euo pipefail
# ----------------------------------------------------------------------------
# Defaults (same variables as setup-git-server.sh, overridable via env vars)
# ----------------------------------------------------------------------------
GITEA_USER="${GITEA_USER:-git}"
GITEA_HOME="${GITEA_HOME:-/home/${GITEA_USER}}"
GITEA_WORK_DIR="${GITEA_WORK_DIR:-/var/lib/gitea}"
GITEA_CONFIG_DIR="${GITEA_CONFIG_DIR:-/etc/gitea}"
GITEA_CONFIG="${GITEA_CONFIG_DIR}/app.ini"
GITEA_BIN="/usr/local/bin/gitea"
OUTPUT=""
KEEP=""
STOP_SERVICE="true"
STAGE_BASE="${STAGE_BASE:-}"
# Filled in while the script runs
DISTRO_FAMILY=""
GITEA_GROUP=""
OUTPUT_DIR=""
OUTPUT_FILE=""
FINAL=""
PARTIAL=""
STAGE=""
STOPPED="false"
BACKUP_VERSION=""
# ----------------------------------------------------------------------------
# Small helpers
# ----------------------------------------------------------------------------
C_GREEN='\033[0;32m'; C_YELLOW='\033[0;33m'; C_RED='\033[0;31m'; C_RESET='\033[0m'
log() { printf '%b\n' "${C_GREEN}[+]${C_RESET} $*"; }
warn() { printf '%b\n' "${C_YELLOW}[!]${C_RESET} $*" >&2; }
error_exit() { printf '%b\n' "${C_RED}[x]${C_RESET} $*" >&2; exit 1; }
start_service_if_stopped() {
# Idempotent: only acts if *this script* stopped the service.
if [[ "$STOPPED" == "true" ]]; then
STOPPED="false"
log "Starting Gitea again..."
systemctl start gitea \
|| warn "Could not start Gitea -- start it by hand: systemctl start gitea"
fi
}
cleanup() {
local rc=$?
# Whatever happened, don't leave the server down.
start_service_if_stopped || true
if [[ -n "${STAGE:-}" && -d "$STAGE" ]]; then
rm -rf -- "$STAGE"
fi
if [[ -n "${PARTIAL:-}" ]]; then
rm -f -- "$PARTIAL"
fi
if [[ $rc -ne 0 ]]; then
warn "The backup did not complete; no backup file was written."
fi
}
trap cleanup EXIT
print_help() {
cat <<'EOF'
Usage: sudo ./backup_create.sh [options]
Creates a single zip file with the whole Gitea instance (database,
repositories, data, custom files, app.ini + secrets). Restore it with
backup_restore.sh.
Options:
-o, --output <path> Where to put the backup. A folder gets a file named
gitea-backup-<host>-<date>-<time>.zip inside it; a path
ending in .zip is used as the exact file name.
(default: the current directory)
--keep <n> After a successful backup, delete all but the <n> newest
gitea-backup-*.zip files in the output folder (for cron).
--no-stop Don't stop Gitea during the dump. No downtime, but the
database and repositories may differ slightly if
someone pushes while the dump runs.
--tmp-dir <dir> Where the archive is built before it is moved to its
final place (default: the Gitea work dir). Needs free
space of about the size of the backup and must be
reachable by the Gitea service user.
-h, --help Show this help and exit
The environment variables of setup-git-server.sh (GITEA_USER, GITEA_HOME,
GITEA_WORK_DIR, GITEA_CONFIG_DIR) are honoured if you used non-default ones.
The backup contains secrets (app.ini, SECRET_KEY, ...). It is written with
mode 600; keep it somewhere private.
Examples:
sudo ./backup_create.sh
sudo ./backup_create.sh -o /mnt/usb/backups --keep 7
sudo ./backup_create.sh -o /root/gitea-before-upgrade.zip
EOF
}
require_root() {
if [[ "${EUID}" -ne 0 ]]; then
error_exit "Please run this script as root, e.g.: sudo $0"
fi
command -v systemctl &>/dev/null || error_exit "This script requires systemd."
[[ -d /run/systemd/system ]] \
|| error_exit "systemd is not running as init here (container/chroot/WSL?). Run this on a real VM or host."
}
need_arg() { # usage: need_arg "$@" -- an option that takes a value must have one
[[ $# -ge 2 ]] || error_exit "Option $1 needs a value (see --help)"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) need_arg "$@"; OUTPUT="$2"; shift 2 ;;
--keep) need_arg "$@"; KEEP="$2"; shift 2 ;;
--no-stop) STOP_SERVICE="false"; shift ;;
--tmp-dir) need_arg "$@"; STAGE_BASE="$(readlink -f -- "$2")"; shift 2 ;;
-h|--help) print_help; exit 0 ;;
*) error_exit "Unknown option: $1 (see --help)" ;;
esac
done
if [[ -n "$KEEP" ]]; then
[[ "$KEEP" =~ ^[0-9]+$ ]] && (( 10#$KEEP >= 1 )) \
|| error_exit "Invalid --keep value '${KEEP}' (expected a number >= 1)."
KEEP=$((10#$KEEP))
[[ "${OUTPUT,,}" != *.zip ]] \
|| error_exit "--keep needs an output *folder*, not a fixed .zip file name."
fi
}
# ----------------------------------------------------------------------------
# app.ini helper (section-aware)
# ----------------------------------------------------------------------------
ini_get() { # usage: ini_get FILE SECTION KEY (SECTION "" = top of the file)
local file="$1" section="$2" key="$3"
[[ -r "$file" ]] || return 0
awk -v want="$section" -v key="$key" '
/^[[:space:]]*[;#]/ { next }
/^[[:space:]]*\[/ { s = $0; gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", s); cur = s; next }
tolower(cur) == tolower(want) {
i = index($0, "=")
if (!i) next
k = substr($0, 1, i - 1); gsub(/[[:space:]]/, "", k)
if (k == key) {
v = substr($0, i + 1)
sub(/^[[:space:]]+/, "", v); sub(/[[:space:]]+$/, "", v)
print v; exit
}
}' "$file"
}
# ----------------------------------------------------------------------------
# Detection helpers / dependencies
# ----------------------------------------------------------------------------
detect_distro_family() {
[[ -r /etc/os-release ]] || error_exit "Cannot read /etc/os-release; unsupported system."
# shellcheck disable=SC1091
. /etc/os-release
case "${ID:-}" in
ubuntu|debian|linuxmint|pop|raspbian) DISTRO_FAMILY="debian" ;;
fedora|rhel|centos|rocky|almalinux|nobara) DISTRO_FAMILY="fedora" ;;
arch|archarm|manjaro|endeavouros) DISTRO_FAMILY="arch" ;;
*)
case "${ID_LIKE:-}" in
*debian*) DISTRO_FAMILY="debian" ;;
*fedora*|*rhel*) DISTRO_FAMILY="fedora" ;;
*arch*) DISTRO_FAMILY="arch" ;;
*) error_exit "Unsupported distro: ${PRETTY_NAME:-${ID:-unknown}}. This script supports Arch, Debian, Ubuntu and Fedora." ;;
esac
;;
esac
}
install_dependencies() {
# Only what's missing: zip (to add the info file) and unzip (to verify).
local pkgs=()
command -v zip &>/dev/null || pkgs+=(zip)
command -v unzip &>/dev/null || pkgs+=(unzip)
(( ${#pkgs[@]} )) || return 0
detect_distro_family
log "Installing dependencies: ${pkgs[*]}..."
case "$DISTRO_FAMILY" in
debian)
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y "${pkgs[@]}"
;;
fedora) dnf install -y "${pkgs[@]}" ;;
# Never do a partial upgrade (-Sy without -u) on Arch.
arch) pacman -Syu --noconfirm --needed "${pkgs[@]}" ;;
esac
}
run_as_gitea() {
# Run a command as the service user, from a directory it can read, with the
# same work dir the service uses.
( cd "$GITEA_WORK_DIR" && sudo -H -u "$GITEA_USER" env GITEA_WORK_DIR="$GITEA_WORK_DIR" "$@" )
}
gitea_version() {
"$GITEA_BIN" --version 2>/dev/null \
| sed -nE 's/^Gitea version v?([0-9]+(\.[0-9]+)+).*/\1/p' \
| head -n1 || true
}
# ----------------------------------------------------------------------------
# Backup steps
# ----------------------------------------------------------------------------
check_installation() {
[[ -x "$GITEA_BIN" ]] \
|| error_exit "Gitea is not installed at ${GITEA_BIN}. Nothing to back up."
[[ -r "$GITEA_CONFIG" ]] \
|| error_exit "${GITEA_CONFIG} not found. Nothing to back up."
id -u "$GITEA_USER" &>/dev/null \
|| error_exit "User '${GITEA_USER}' does not exist."
command -v sudo &>/dev/null || error_exit "sudo is required (setup-git-server.sh installs it)."
GITEA_HOME="$(getent passwd "$GITEA_USER" | cut -d: -f6)"
GITEA_GROUP="$(id -gn "$GITEA_USER")"
local db_type
db_type="$(ini_get "$GITEA_CONFIG" database DB_TYPE | tr -d '"`')"
if [[ "${db_type,,}" != "sqlite3" ]]; then
warn "DB_TYPE is '${db_type}': the backup will work, but backup_restore.sh can only restore SQLite instances."
fi
}
resolve_output() {
local ts host
ts="$(date +%Y%m%d-%H%M%S)"
host="$(printf '%s' "$(uname -n)" | tr -c 'A-Za-z0-9._-' '_')"
if [[ -z "$OUTPUT" ]]; then
OUTPUT_DIR="$PWD"
OUTPUT_FILE="gitea-backup-${host}-${ts}.zip"
elif [[ "${OUTPUT,,}" == *.zip ]]; then
OUTPUT_DIR="$(dirname -- "$OUTPUT")"
OUTPUT_FILE="$(basename -- "$OUTPUT")"
else
OUTPUT_DIR="$OUTPUT"
OUTPUT_FILE="gitea-backup-${host}-${ts}.zip"
fi
mkdir -p -- "$OUTPUT_DIR"
OUTPUT_DIR="$(cd -- "$OUTPUT_DIR" && pwd -P)"
FINAL="${OUTPUT_DIR}/${OUTPUT_FILE}"
[[ ! -e "$FINAL" ]] || error_exit "${FINAL} already exists; refusing to overwrite it."
}
stop_service() {
if [[ "$STOP_SERVICE" != "true" ]]; then
warn "Backing up while Gitea keeps running (--no-stop)."
return 0
fi
if systemctl is-active --quiet gitea 2>/dev/null; then
log "Stopping Gitea so the backup is consistent..."
systemctl stop gitea
STOPPED="true"
fi
}
create_dump() {
: "${STAGE_BASE:=$GITEA_WORK_DIR}"
mkdir -p -- "$STAGE_BASE"
STAGE="$(mktemp -d -p "$STAGE_BASE" .backup.XXXXXX)"
# The dump runs as the service user (it reads app.ini via the group), so it
# must own the folder it writes into.
chown "${GITEA_USER}:${GITEA_GROUP}" "$STAGE"
chmod 700 "$STAGE"
log "Running 'gitea dump' (this can take a while for big instances)..."
run_as_gitea "$GITEA_BIN" --config "$GITEA_CONFIG" dump \
--file "$STAGE/dump.zip" --type zip --tempdir "$STAGE" --skip-log \
|| error_exit "'gitea dump' failed -- see the error above."
[[ -s "$STAGE/dump.zip" ]] || error_exit "'gitea dump' did not produce a file."
}
add_manifest() {
local info="${STAGE}/gitea-backup-info.txt"
BACKUP_VERSION="$(gitea_version)"
# Plain KEY=VALUE, read back (never sourced) by backup_restore.sh.
cat > "$info" <<EOF
# Written by backup_create.sh -- read by backup_restore.sh
FORMAT=1
CREATED_UTC=$(date -u +%Y-%m-%dT%H:%M:%SZ)
HOSTNAME=$(uname -n)
GITEA_VERSION=${BACKUP_VERSION}
DOMAIN=$(ini_get "$GITEA_CONFIG" server DOMAIN)
DB_TYPE=$(ini_get "$GITEA_CONFIG" database DB_TYPE)
EOF
(cd "$STAGE" && zip -q -g dump.zip gitea-backup-info.txt) \
|| error_exit "Could not add the info file to the archive."
}
verify_archive() {
log "Verifying the archive..."
unzip -tq "$STAGE/dump.zip" >/dev/null \
|| error_exit "The archive failed its integrity test."
# (awk reads everything; 'grep -q' could end the pipe early and trip pipefail.)
unzip -Z1 "$STAGE/dump.zip" \
| awk '$0 == "gitea-db.sql" { found = 1 } END { exit !found }' \
|| error_exit "The archive contains no gitea-db.sql -- the dump is incomplete."
}
deliver_archive() {
# Move to the destination under a temporary name first, so a half-copied
# file never looks like a finished backup.
PARTIAL="${FINAL}.partial"
mv -- "$STAGE/dump.zip" "$PARTIAL"
chown root:root "$PARTIAL"
chmod 600 "$PARTIAL"
# When started through sudo, hand the file to the person who ran it.
if [[ -n "${SUDO_USER:-}" && "$SUDO_USER" != "root" ]] && id -u "$SUDO_USER" &>/dev/null; then
chown -- "$SUDO_USER" "$PARTIAL"
fi
mv -- "$PARTIAL" "$FINAL"
PARTIAL=""
}
prune_old() {
[[ -n "$KEEP" ]] || return 0
local entry n=0
while IFS= read -r -d '' entry; do
n=$((n + 1))
(( n > KEEP )) || continue
rm -f -- "${entry#* }"
log "Removed old backup: ${entry#* }"
done < <(find "$OUTPUT_DIR" -maxdepth 1 -type f -name 'gitea-backup-*.zip' -printf '%T@ %p\0' | sort -z -rn)
}
print_summary() {
local size
size="$(du -h -- "$FINAL" | awk '{print $1}')"
cat <<EOF
============================================================================
Backup created
============================================================================
File ${FINAL}
Size ${size}
Gitea ${BACKUP_VERSION:-unknown}
Contains the database, all repositories, data (LFS, attachments, avatars),
custom files and app.ini with its secrets -- keep it private.
Restore it with:
sudo ./backup_restore.sh ${FINAL}
On a new machine, first install the same (or a newer) Gitea:
sudo ./setup-git-server.sh --version ${BACKUP_VERSION:-<x.y.z>}
============================================================================
EOF
}
# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------
main() {
require_root
install_dependencies
check_installation
resolve_output
log "Backing up to ${FINAL}"
stop_service
create_dump
start_service_if_stopped # downtime ends here; the rest doesn't need it down
add_manifest
verify_archive
deliver_archive
prune_old
print_summary
}
# Only run when executed, not when sourced (handy for testing single functions).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
parse_args "$@"
main
fi