414 lines
15 KiB
Bash
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
|