Add gitea-host backup scripts
This commit is contained in:
1 parent
a6ad39e605
commit
8a8edf9fcb
2 files changed
+503
-30
No files matched your search
@@ -0,0 +1,413 @@
|
||||
#!/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
|
||||
Reference in new issue
Block a user