Added gitea server with podman
This commit is contained in:
1 parent
aa5776af18
commit
361e1c9b51
4 files changed
+1608
No files matched your search
File renamed without changes.
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# gitea-podman-backup.sh
|
||||
#
|
||||
# Creates a consistent, verified backup of ALL data of the Gitea container installed by
|
||||
# setup-gitea-podman.sh: repositories, SQLite database, LFS objects, attachments, avatars,
|
||||
# packages, SSH host keys, custom/ files and app.ini (whose secrets the encrypted data in
|
||||
# the database depends on -- a database backup without them is not restorable).
|
||||
#
|
||||
# How it stays consistent
|
||||
# The service is stopped (gracefully, so Gitea and SQLite shut down cleanly), the data
|
||||
# directories are archived while nothing can write to them, and the service is started
|
||||
# again. Typical downtime: seconds to a few minutes, depending on the amount of data.
|
||||
# The service is ALWAYS started again if this script stopped it -- also on errors and
|
||||
# on Ctrl-C/SIGTERM.
|
||||
#
|
||||
# What "verified" means (all of it happens before an old backup could ever be pruned)
|
||||
# 1. the archive is written under a temporary name (*.partial) and only renamed into
|
||||
# place after everything below succeeded, so a crash never leaves a half-written
|
||||
# file that looks like a backup;
|
||||
# 2. gzip integrity test + tar structure test + required members are present;
|
||||
# 3. the archive is compared byte-for-byte against the live data (tar --compare) while
|
||||
# the service is still stopped (skip with --fast);
|
||||
# 4. a SHA-256 sidecar file is written next to the archive;
|
||||
# 5. after the restart, the SQLite database inside the archive is extracted to a scratch
|
||||
# directory and checked with PRAGMA integrity_check.
|
||||
#
|
||||
# Retention: with --keep N the newest N backups are kept and older ones deleted -- but only
|
||||
# after the new backup passed every check, and only files this script named itself.
|
||||
# Without --keep nothing is ever deleted.
|
||||
#
|
||||
# Usage: sudo gitea-podman-backup [options]
|
||||
# Options:
|
||||
# --dest DIR backup directory (default: BACKUP_DIR from the config file)
|
||||
# --keep N keep only the newest N backups (N >= 1) after a fully successful run
|
||||
# --dry-run check everything and print the plan, but stop/change nothing
|
||||
# --fast skip the byte-for-byte comparison (shorter downtime, weaker check)
|
||||
# --skip-db-check don't run sqlite3 integrity_check on the archived database
|
||||
# --wait SECONDS how long to wait for Gitea to be healthy again (default 300)
|
||||
# --quiet only print warnings and errors (for timers/cron)
|
||||
# -h, --help
|
||||
#
|
||||
# Exit codes: 0 backup complete and verified
|
||||
# 1 failed -- no new backup was created (the service was restarted)
|
||||
# 2 usage error
|
||||
# 3 backup file created, but a post-check needs attention (database
|
||||
# integrity problem, or Gitea not healthy after the restart) -- old
|
||||
# backups were NOT pruned
|
||||
# 75 another backup/restore is running (lock held)
|
||||
#
|
||||
# Backups contain secrets (app.ini, password hashes, 2FA seeds): they are written with
|
||||
# mode 0600 in a 0700 directory. Copy them off this machine -- a backup on the same disk
|
||||
# does not protect against losing the disk.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_VERSION="1"
|
||||
CONF_FILE="${GITEA_PODMAN_CONF:-/etc/gitea-podman/gitea-podman.conf}"
|
||||
LOCK_FILE="${GITEA_PODMAN_LOCK:-/run/lock/gitea-podman.lock}"
|
||||
QUADLET_DIR="${GITEA_QUADLET_DIR:-/etc/containers/systemd}"
|
||||
|
||||
OPT_DEST=""; OPT_KEEP=""; DRY_RUN=0; FAST=0; QUIET=0; SKIP_DB_CHECK=0; WAIT_SECS=300
|
||||
|
||||
# Filled from the config file
|
||||
DATA_ROOT=""; BACKUP_DIR=""; CONTAINER_NAME=""; UNIT_NAME=""; IMAGE=""
|
||||
CONTAINER_UID=""; CONTAINER_GID=""; HTTP_BIND=""; HTTP_PORT=""; HEALTH_URL=""
|
||||
|
||||
# Run state, used by the cleanup trap
|
||||
STOPPED_BY_US=0
|
||||
WORKDIR_TMP=""
|
||||
PARTIAL=""
|
||||
FINAL=""
|
||||
IMAGE_ID=""; IMAGE_DIGEST=""; GITEA_VERSION_TAG="unknown"
|
||||
WORK_BYTES=0; CONFIG_BYTES=0
|
||||
DOWNTIME_SECS=0
|
||||
|
||||
log() { if (( ! QUIET )); then printf '%s [+] %s\n' "$(date '+%F %T')" "$*"; fi; }
|
||||
warn() { printf '%s [!] %s\n' "$(date '+%F %T')" "$*" >&2; }
|
||||
die() { printf '%s [x] %s\n' "$(date '+%F %T')" "$*" >&2; exit 1; }
|
||||
|
||||
usage() { sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Config file (plain KEY=value lines written by the setup script; never sourced)
|
||||
# ----------------------------------------------------------------------------
|
||||
conf_get() {
|
||||
[[ -r "$CONF_FILE" ]] || return 0
|
||||
sed -n "s/^${1}=//p" "$CONF_FILE" | head -n1
|
||||
}
|
||||
|
||||
ini_get() { # ini_get <file> <section> <key>
|
||||
awk -v sec="$2" -v key="$3" '
|
||||
/^[[:space:]]*[;#]/ { next }
|
||||
/^[[:space:]]*\[/ { cur = $0; gsub(/[][[:space:]]/, "", cur); next }
|
||||
{ k = $0; sub(/[[:space:]]*=.*/, "", k); gsub(/^[[:space:]]+/, "", k)
|
||||
if (cur == sec && k == key) { v = $0; sub(/^[^=]*=[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v); print v; exit } }' "$1"
|
||||
}
|
||||
|
||||
load_conf() {
|
||||
[[ -f "$CONF_FILE" ]] || die "Config file $CONF_FILE not found -- run setup-gitea-podman.sh first."
|
||||
local owner mode
|
||||
owner="$(stat -c '%u' "$CONF_FILE")"; mode="$(stat -c '%a' "$CONF_FILE")"
|
||||
[[ "$owner" == 0 ]] || die "$CONF_FILE must be owned by root."
|
||||
(( (8#$mode & 8#022) == 0 )) || die "$CONF_FILE must not be writable by group/others."
|
||||
|
||||
DATA_ROOT="$(conf_get DATA_ROOT)"
|
||||
BACKUP_DIR="$(conf_get BACKUP_DIR)"
|
||||
CONTAINER_NAME="$(conf_get CONTAINER_NAME)"
|
||||
UNIT_NAME="$(conf_get UNIT_NAME)"
|
||||
IMAGE="$(conf_get IMAGE)"
|
||||
CONTAINER_UID="$(conf_get CONTAINER_UID)"
|
||||
CONTAINER_GID="$(conf_get CONTAINER_GID)"
|
||||
HTTP_BIND="$(conf_get HTTP_BIND)"
|
||||
HTTP_PORT="$(conf_get HTTP_PORT)"
|
||||
local k
|
||||
for k in DATA_ROOT BACKUP_DIR CONTAINER_NAME UNIT_NAME IMAGE CONTAINER_UID CONTAINER_GID HTTP_BIND HTTP_PORT; do
|
||||
[[ -n "${!k}" ]] || die "$k is missing in $CONF_FILE."
|
||||
done
|
||||
[[ "$DATA_ROOT" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "Suspicious DATA_ROOT in $CONF_FILE."
|
||||
[[ "$BACKUP_DIR" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "Suspicious BACKUP_DIR in $CONF_FILE."
|
||||
local host="$HTTP_BIND"
|
||||
[[ "$host" != "0.0.0.0" ]] || host="127.0.0.1"
|
||||
HEALTH_URL="http://${host}:${HTTP_PORT}/api/healthz"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Service helpers
|
||||
# ----------------------------------------------------------------------------
|
||||
svc_active() {
|
||||
local s
|
||||
s="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)"
|
||||
case "$s" in active|activating|reloading) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
container_running() {
|
||||
[[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" == "true" ]]
|
||||
}
|
||||
|
||||
wait_healthy() { # wait_healthy <seconds>
|
||||
local limit="$1" i
|
||||
for (( i = 0; i < limit; i += 2 )); do
|
||||
if curl -fsS --max-time 5 -o /dev/null "$HEALTH_URL" 2>/dev/null; then return 0; fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Cleanup: always undo what we changed (partial files, temp dir, stopped service)
|
||||
# ----------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2329 # invoked via 'trap cleanup EXIT', not called directly
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
trap - EXIT INT TERM
|
||||
if [[ -n "$PARTIAL" && -e "$PARTIAL" ]]; then rm -f -- "$PARTIAL"; fi
|
||||
if [[ -n "$WORKDIR_TMP" && -d "$WORKDIR_TMP" && "$WORKDIR_TMP" == "${BACKUP_DIR}/.tmp."* ]]; then
|
||||
rm -rf -- "$WORKDIR_TMP"
|
||||
fi
|
||||
if (( STOPPED_BY_US )); then
|
||||
warn "Starting $UNIT_NAME again (it was stopped for the backup)..."
|
||||
if systemctl start "$UNIT_NAME"; then
|
||||
STOPPED_BY_US=0
|
||||
else
|
||||
warn "!!! COULD NOT START $UNIT_NAME -- start it manually: systemctl start $UNIT_NAME"
|
||||
(( rc != 0 )) || rc=1
|
||||
fi
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ----------------------------------------------------------------------------
|
||||
need_arg() { [[ $# -ge 2 ]] || { warn "Option $1 needs a value (see --help)"; exit 2; }; }
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dest) need_arg "$@"; OPT_DEST="$2"; shift 2 ;;
|
||||
--keep) need_arg "$@"; OPT_KEEP="$2"; shift 2 ;;
|
||||
--wait) need_arg "$@"; WAIT_SECS="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--fast) FAST=1; shift ;;
|
||||
--skip-db-check) SKIP_DB_CHECK=1; shift ;;
|
||||
--quiet) QUIET=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) warn "Unknown option: $1 (see --help)"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if [[ -n "$OPT_KEEP" ]]; then
|
||||
if [[ ! "$OPT_KEEP" =~ ^[0-9]+$ ]] || (( OPT_KEEP < 1 )); then
|
||||
warn "--keep needs a number >= 1"; exit 2
|
||||
fi
|
||||
fi
|
||||
[[ "$WAIT_SECS" =~ ^[0-9]+$ ]] || { warn "--wait needs a number of seconds"; exit 2; }
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Steps
|
||||
# ----------------------------------------------------------------------------
|
||||
preflight() {
|
||||
[[ $EUID -eq 0 ]] || die "Run this as root (sudo)."
|
||||
local c
|
||||
for c in podman systemctl tar gzip sha256sum flock curl awk sed df du stat realpath date; do
|
||||
command -v "$c" >/dev/null 2>&1 || die "Required command not found: $c"
|
||||
done
|
||||
if (( ! SKIP_DB_CHECK )); then
|
||||
command -v sqlite3 >/dev/null 2>&1 || die "sqlite3 is needed to verify the database backup (install it, or pass --skip-db-check)."
|
||||
fi
|
||||
load_conf
|
||||
[[ -n "$OPT_DEST" ]] && BACKUP_DIR="$OPT_DEST"
|
||||
[[ "$BACKUP_DIR" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "--dest must be an absolute path (letters, digits, _ . / - only)."
|
||||
|
||||
[[ -d "$DATA_ROOT/work" && -d "$DATA_ROOT/config" ]] \
|
||||
|| die "$DATA_ROOT/work or $DATA_ROOT/config is missing -- is the data disk mounted? Refusing to back up an empty tree."
|
||||
[[ -f "$DATA_ROOT/config/app.ini" ]] || die "$DATA_ROOT/config/app.ini is missing -- refusing to make a backup without the config/secrets."
|
||||
[[ -e "$DATA_ROOT/work/.gitea-podman-data" ]] \
|
||||
|| die "Marker file $DATA_ROOT/work/.gitea-podman-data is missing -- this does not look like the Gitea data directory."
|
||||
systemctl cat "$UNIT_NAME" >/dev/null 2>&1 || die "systemd unit $UNIT_NAME not found -- run setup-gitea-podman.sh first."
|
||||
|
||||
# The backup directory must be separate from the data it protects.
|
||||
local rd rb
|
||||
rd="$(realpath -m -- "$DATA_ROOT")"; rb="$(realpath -m -- "$BACKUP_DIR")"
|
||||
case "$rb/" in "$rd/"*) die "Backup directory $rb is inside the data directory $rd." ;; esac
|
||||
case "$rd/" in "$rb/"*) die "Data directory $rd is inside the backup directory $rb." ;; esac
|
||||
BACKUP_DIR="$rb"
|
||||
|
||||
if [[ ! -d "$BACKUP_DIR" ]]; then
|
||||
if (( DRY_RUN )); then log "(dry-run) would create $BACKUP_DIR"; else mkdir -p -- "$BACKUP_DIR"; chmod 0700 -- "$BACKUP_DIR"; fi
|
||||
fi
|
||||
if [[ -d "$BACKUP_DIR" ]]; then
|
||||
[[ -w "$BACKUP_DIR" ]] || die "$BACKUP_DIR is not writable."
|
||||
if [[ "$(stat -c %d -- "$DATA_ROOT")" == "$(stat -c %d -- "$BACKUP_DIR")" ]]; then
|
||||
warn "$BACKUP_DIR is on the same filesystem as the data: this does not protect against disk failure. Copy the backups elsewhere too."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
check_space() {
|
||||
WORK_BYTES="$(du -sb -- "$DATA_ROOT/work" | awk '{print $1}')"
|
||||
CONFIG_BYTES="$(du -sb -- "$DATA_ROOT/config" | awk '{print $1}')"
|
||||
local used need avail probe="$BACKUP_DIR"
|
||||
used=$(( WORK_BYTES + CONFIG_BYTES ))
|
||||
need=$(( used + used / 10 + 268435456 )) # +10% and 256 MiB headroom, assuming zero compression
|
||||
[[ -d "$probe" ]] || probe="$(dirname -- "$BACKUP_DIR")"
|
||||
avail="$(df -B1 --output=avail -- "$probe" | tail -n1 | tr -d ' ')"
|
||||
log "Data size: $(numfmt --to=iec "$used"); free space in backup dir: $(numfmt --to=iec "$avail")"
|
||||
if (( avail < need )); then
|
||||
die "Not enough free space for a safe backup: need about $(numfmt --to=iec "$need"), have $(numfmt --to=iec "$avail"). Nothing was stopped or changed."
|
||||
fi
|
||||
}
|
||||
|
||||
collect_info() { # while the container still exists
|
||||
IMAGE_ID="$(podman image inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null || true)"
|
||||
IMAGE_DIGEST="$(podman image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$IMAGE" 2>/dev/null || true)"
|
||||
local v
|
||||
v="$(sed -nE 's/.*:([0-9]+\.[0-9]+\.[0-9]+)(-rootless)?$/\1/p' <<<"$IMAGE")"
|
||||
GITEA_VERSION_TAG="${v:-unknown}"
|
||||
}
|
||||
|
||||
warn_if_data_outside_volumes() { # data written to the container's own layer would NOT be in the backup
|
||||
container_running || return 0
|
||||
local unexpected
|
||||
# podman diff reports the two bind-mount points themselves (and their parent
|
||||
# directory chain, created as a side effect of setting up the mount) as "added"
|
||||
# even though their CONTENTS are the host volumes, not container-layer writes.
|
||||
# Excluding exactly those directories (not their contents) leaves genuine
|
||||
# container-layer writes elsewhere still flagged.
|
||||
unexpected="$(podman diff "$CONTAINER_NAME" 2>/dev/null | awk '
|
||||
$2 == "/etc" || $2 == "/tmp" || $2 ~ /^\/tmp\// || $2 == "/run" || $2 ~ /^\/run\// || $2 ~ /^\/dev\// { next }
|
||||
$2 == "/etc/gitea" || $2 == "/var" || $2 == "/var/lib" || $2 == "/var/lib/gitea" { next }
|
||||
{ print }' | head -n 10)" || true
|
||||
if [[ -n "$unexpected" ]]; then
|
||||
warn "The container has files OUTSIDE its persistent volumes; they are NOT covered by this backup:"
|
||||
printf '%s\n' "$unexpected" | sed 's/^/ /' >&2
|
||||
fi
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
if svc_active; then
|
||||
log "Stopping $UNIT_NAME (graceful; up to 2.5 min)..."
|
||||
STOPPED_BY_US=1 # set first: an interrupted/failed stop is undone by the trap too
|
||||
systemctl stop "$UNIT_NAME" || die "Could not stop $UNIT_NAME."
|
||||
else
|
||||
log "$UNIT_NAME is not running; backing up the data as it is."
|
||||
fi
|
||||
if container_running; then
|
||||
die "Container $CONTAINER_NAME is still running after the stop -- refusing to back up live data."
|
||||
fi
|
||||
}
|
||||
|
||||
write_manifest() {
|
||||
local f="$WORKDIR_TMP/BACKUP-INFO.txt" app="$DATA_ROOT/config/app.ini"
|
||||
mkdir -p -- "$WORKDIR_TMP/infra"
|
||||
[[ -f "$CONF_FILE" ]] && cp -p -- "$CONF_FILE" "$WORKDIR_TMP/infra/gitea-podman.conf"
|
||||
local q="${QUADLET_DIR}/${UNIT_NAME%.service}.container"
|
||||
[[ -f "$q" ]] && cp -p -- "$q" "$WORKDIR_TMP/infra/$(basename -- "$q")"
|
||||
{
|
||||
echo "format=1"
|
||||
echo "created_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "hostname=$(hostname 2>/dev/null || echo unknown)"
|
||||
echo "script_version=$SCRIPT_VERSION"
|
||||
echo "container=$CONTAINER_NAME"
|
||||
echo "image=$IMAGE"
|
||||
echo "image_id=$IMAGE_ID"
|
||||
echo "image_digest=$IMAGE_DIGEST"
|
||||
echo "gitea_version=$GITEA_VERSION_TAG"
|
||||
echo "data_root=$DATA_ROOT"
|
||||
echo "container_uid=$CONTAINER_UID"
|
||||
echo "container_gid=$CONTAINER_GID"
|
||||
echo "work_bytes=$WORK_BYTES"
|
||||
echo "config_bytes=$CONFIG_BYTES"
|
||||
echo "db_type=$(ini_get "$app" database DB_TYPE)"
|
||||
echo "root_url=$(ini_get "$app" server ROOT_URL)"
|
||||
echo "ssh_port=$(ini_get "$app" server SSH_PORT)"
|
||||
} > "$f"
|
||||
}
|
||||
|
||||
create_archive() {
|
||||
log "Archiving $DATA_ROOT/{work,config} -> $(basename -- "$PARTIAL") ..."
|
||||
local st
|
||||
set +e
|
||||
tar --create --file=- --numeric-owner \
|
||||
-C "$WORKDIR_TMP" BACKUP-INFO.txt infra \
|
||||
-C "$DATA_ROOT" config work \
|
||||
| gzip -c > "$PARTIAL"
|
||||
st=("${PIPESTATUS[@]}")
|
||||
set -e
|
||||
# tar exit 1 means "a file changed while being read" -- unacceptable for a consistent snapshot.
|
||||
[[ "${st[0]}" -eq 0 && "${st[1]}" -eq 0 ]] || die "Archiving failed (tar exit ${st[0]}, gzip exit ${st[1]}); no backup was created."
|
||||
sync -f -- "$PARTIAL" || true
|
||||
}
|
||||
|
||||
verify_archive_structure() {
|
||||
log "Verifying archive integrity..."
|
||||
gzip -t -- "$PARTIAL" || die "gzip integrity test failed -- discarding the archive."
|
||||
tar -tzf "$PARTIAL" > "$WORKDIR_TMP/listing.txt" || die "tar cannot read the archive back -- discarding it."
|
||||
local m
|
||||
for m in BACKUP-INFO.txt config/app.ini work/.gitea-podman-data; do
|
||||
grep -qxF -- "$m" "$WORKDIR_TMP/listing.txt" || die "Archive is missing required member '$m' -- discarding it."
|
||||
done
|
||||
if [[ "$DB_REL" != "" ]]; then
|
||||
grep -qxF -- "$DB_REL" "$WORKDIR_TMP/listing.txt" || die "Archive is missing the database file '$DB_REL' -- discarding it."
|
||||
fi
|
||||
}
|
||||
|
||||
verify_archive_content() {
|
||||
if (( FAST )); then
|
||||
warn "--fast: skipping the byte-for-byte comparison of the archive with the live data."
|
||||
return 0
|
||||
fi
|
||||
log "Comparing the archive with the (stopped) data, byte for byte..."
|
||||
tar -dzf "$PARTIAL" --numeric-owner -C "$DATA_ROOT" config work \
|
||||
|| die "The archive does NOT match the data on disk -- discarding it (nothing was pruned)."
|
||||
tar -dzf "$PARTIAL" --numeric-owner -C "$WORKDIR_TMP" BACKUP-INFO.txt infra \
|
||||
|| die "The archive's metadata does not match -- discarding it."
|
||||
}
|
||||
|
||||
finalize_archive() {
|
||||
local hash
|
||||
hash="$(sha256sum -- "$PARTIAL" | awk '{print $1}')"
|
||||
[[ -n "$hash" ]] || die "Could not compute the SHA-256 of the archive."
|
||||
# Sidecar first, archive last: a crash in between leaves at most an orphan sidecar.
|
||||
printf '%s %s\n' "$hash" "$(basename -- "$FINAL")" > "${FINAL}.sha256.tmp"
|
||||
mv -T -- "${FINAL}.sha256.tmp" "${FINAL}.sha256"
|
||||
[[ ! -e "$FINAL" ]] || die "$FINAL already exists -- refusing to overwrite a backup."
|
||||
mv -T -- "$PARTIAL" "$FINAL"
|
||||
PARTIAL=""
|
||||
sync -f -- "$BACKUP_DIR" || true
|
||||
ARCHIVE_SHA="$hash"
|
||||
}
|
||||
|
||||
restart_service() {
|
||||
(( STOPPED_BY_US )) || return 0
|
||||
log "Starting $UNIT_NAME again..."
|
||||
systemctl start "$UNIT_NAME" || die "COULD NOT START $UNIT_NAME (the backup itself is safe): start it manually."
|
||||
STOPPED_BY_US=0
|
||||
DOWNTIME_SECS=$(( SECONDS - T_STOP ))
|
||||
}
|
||||
|
||||
post_verify() { # exit-status contract: number of problems found
|
||||
local problems=0
|
||||
log "Re-checking the finished archive..."
|
||||
if ! ( cd -- "$BACKUP_DIR" && sha256sum --check --status -- "$(basename -- "$FINAL").sha256" ); then
|
||||
warn "SHA-256 of the finished archive does not match its sidecar file!"
|
||||
problems=$(( problems + 1 ))
|
||||
fi
|
||||
if (( ! SKIP_DB_CHECK )) && [[ -n "$DB_REL" ]]; then
|
||||
local tmp="$WORKDIR_TMP/dbcheck" res
|
||||
mkdir -p -- "$tmp"
|
||||
if tar -xzf "$FINAL" -C "$tmp" --numeric-owner --wildcards "${DB_REL}*" 2>/dev/null && [[ -f "$tmp/$DB_REL" ]]; then
|
||||
res="$(sqlite3 "$tmp/$DB_REL" 'PRAGMA integrity_check;' 2>&1 || true)"
|
||||
if [[ "$res" == "ok" ]]; then
|
||||
log "SQLite integrity_check on the archived database: ok"
|
||||
else
|
||||
warn "SQLite integrity_check on the archived database FAILED:"
|
||||
printf '%s\n' "$res" | head -n 8 | sed 's/^/ /' >&2
|
||||
problems=$(( problems + 1 ))
|
||||
fi
|
||||
else
|
||||
warn "Could not extract the database from the archive for the integrity check."
|
||||
problems=$(( problems + 1 ))
|
||||
fi
|
||||
fi
|
||||
return "$problems"
|
||||
}
|
||||
|
||||
prune_old() {
|
||||
[[ -n "$OPT_KEEP" ]] || return 0
|
||||
local f n=0 removed=0
|
||||
while IFS= read -r f; do
|
||||
n=$(( n + 1 ))
|
||||
if (( n > OPT_KEEP )); then
|
||||
log "Pruning old backup: $(basename -- "$f")"
|
||||
rm -f -- "$f" "${f}.sha256"
|
||||
removed=$(( removed + 1 ))
|
||||
fi
|
||||
done < <(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-????????T??????Z.tar.gz' | sort -r)
|
||||
log "Retention: keeping the newest $OPT_KEEP; removed $removed."
|
||||
}
|
||||
|
||||
cleanup_stale() { # leftovers of a crashed earlier run (we hold the lock, so nobody else is using them)
|
||||
[[ -d "$BACKUP_DIR" ]] || return 0
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-*.tar.gz.partial' -delete
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-*.sha256.tmp' -delete
|
||||
find "$BACKUP_DIR" -maxdepth 1 -type d -name '.tmp.*' -exec rm -rf -- {} +
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
main() {
|
||||
parse_args "$@"
|
||||
preflight
|
||||
|
||||
local app="$DATA_ROOT/config/app.ini" dbtype dbpath
|
||||
dbtype="$(ini_get "$app" database DB_TYPE)"
|
||||
DB_REL=""
|
||||
if [[ "$dbtype" == "sqlite3" ]]; then
|
||||
dbpath="$(ini_get "$app" database PATH)"
|
||||
case "$dbpath" in
|
||||
/var/lib/gitea/*) DB_REL="work/${dbpath#/var/lib/gitea/}" ;;
|
||||
*) warn "Unexpected SQLite path '$dbpath' in app.ini; the database check will be skipped." ;;
|
||||
esac
|
||||
else
|
||||
warn "DB_TYPE is '${dbtype:-unset}', not sqlite3 -- this script only knows how to verify SQLite."
|
||||
fi
|
||||
|
||||
# Locking is handled by the flock wrapper at the bottom of this file -- by
|
||||
# the time main() runs, the lock is already held for this whole invocation.
|
||||
check_space
|
||||
local ts
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
FINAL="${BACKUP_DIR}/gitea-backup-${ts}.tar.gz"
|
||||
|
||||
if (( DRY_RUN )); then
|
||||
log "(dry-run) would stop $UNIT_NAME, write $FINAL (+ .sha256), verify it and start the service again."
|
||||
[[ -n "$OPT_KEEP" ]] && log "(dry-run) would then keep only the newest $OPT_KEEP backups."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
cleanup_stale
|
||||
WORKDIR_TMP="$(mktemp -d "${BACKUP_DIR}/.tmp.XXXXXXXX")"
|
||||
PARTIAL="${FINAL}.partial"
|
||||
|
||||
warn_if_data_outside_volumes
|
||||
collect_info
|
||||
local was_active=0
|
||||
if svc_active; then was_active=1; fi
|
||||
|
||||
if (( ! was_active )) && container_running; then
|
||||
die "Container $CONTAINER_NAME is running but $UNIT_NAME is not active -- it is not managed by systemd; refusing to touch it."
|
||||
fi
|
||||
|
||||
stop_service
|
||||
T_STOP=$SECONDS
|
||||
write_manifest
|
||||
create_archive
|
||||
verify_archive_structure
|
||||
verify_archive_content
|
||||
finalize_archive
|
||||
if (( was_active )); then restart_service; else DOWNTIME_SECS=$(( SECONDS - T_STOP )); fi
|
||||
|
||||
local rc=0
|
||||
if (( was_active )); then
|
||||
log "Waiting for Gitea to answer on $HEALTH_URL ..."
|
||||
if wait_healthy "$WAIT_SECS"; then
|
||||
log "Gitea is healthy again."
|
||||
else
|
||||
warn "Gitea did not become healthy within ${WAIT_SECS}s after the restart (the backup itself is complete). Check: journalctl -u $UNIT_NAME"
|
||||
rc=3
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! post_verify; then rc=3; fi
|
||||
|
||||
local size
|
||||
size="$(numfmt --to=iec "$(stat -c %s -- "$FINAL")")"
|
||||
if (( rc == 0 )); then
|
||||
prune_old
|
||||
log "DONE: $FINAL ($size), sha256 ${ARCHIVE_SHA:0:16}..., downtime ${DOWNTIME_SECS}s."
|
||||
else
|
||||
warn "Backup written to $FINAL ($size) but needs attention (see above). Old backups were NOT pruned."
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
# One backup/restore at a time -- for real, this time. A plain 'exec 9>lockfile;
|
||||
# flock -n 9' inside main() looks right but isn't: that fd has no close-on-exec
|
||||
# set, so the instant this script starts the Gitea container, podman's conmon
|
||||
# (a daemon that outlives this script entirely) inherits it via fork/exec and
|
||||
# keeps the lock held forever -- every later backup would then wedge on a false
|
||||
# "already running", from someone who last looked stopped the container weeks
|
||||
# ago. Relaunching under the external `flock` command sidesteps this: flock
|
||||
# opens the lockfile itself with O_CLOEXEC, so the fd is already gone by the
|
||||
# time this script (running as flock's child) even starts, and nothing this
|
||||
# script spawns can inherit what it never had.
|
||||
if [[ "${GITEA_PODMAN_BACKUP_RELAUNCHED:-}" != "1" ]]; then
|
||||
for a in "$@"; do [[ "$a" == "-h" || "$a" == "--help" ]] && { usage; exit 0; }; done
|
||||
mkdir -p -- "$(dirname -- "$LOCK_FILE")"
|
||||
export GITEA_PODMAN_BACKUP_RELAUNCHED=1
|
||||
rc=0
|
||||
flock -n -E 75 "$LOCK_FILE" "$0" "$@" || rc=$?
|
||||
[[ $rc -eq 75 ]] && warn "Another gitea-podman backup/restore is running (lock: $LOCK_FILE)."
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# gitea-podman-restore.sh
|
||||
#
|
||||
# Restores Gitea data from an archive made by gitea-podman-backup.sh. This is
|
||||
# a destructive operation by nature (it replaces live data), so the defaults
|
||||
# are the safe ones:
|
||||
# - with no --yes, nothing is touched: the archive is verified and a plan
|
||||
# is printed. --yes is required to actually restore.
|
||||
# - the CURRENT data is never deleted. It is moved aside to a timestamped
|
||||
# directory next to it, and stays there until YOU remove it. If the
|
||||
# restore turns out to be wrong, moving it back is a straight 'mv'.
|
||||
# - the archive is verified (checksum, gzip, tar structure, required
|
||||
# members) BEFORE anything live is touched, and the restored database is
|
||||
# integrity-checked BEFORE the service is started on top of it -- a
|
||||
# restore that fails that check leaves the service stopped rather than
|
||||
# start Gitea against data that might make things worse.
|
||||
# - --target-root lets you do a full restore "drill" into a scratch
|
||||
# directory to prove a backup actually works, without touching the live
|
||||
# service or data at all.
|
||||
#
|
||||
# Shares gitea-podman-backup.sh's config file and lock, so a backup and a
|
||||
# restore can never run at the same time.
|
||||
#
|
||||
# Usage: sudo gitea-podman-restore <backup-file.tar.gz> [options]
|
||||
# or: sudo gitea-podman-restore --latest [options]
|
||||
# Options:
|
||||
# --latest Use the newest backup in BACKUP_DIR instead of a path
|
||||
# --yes Actually perform a live restore (required; without
|
||||
# it this verifies the archive and prints the plan)
|
||||
# --target-root DIR Extract into DIR instead of the live setup -- a
|
||||
# restore drill. DIR must not already exist. Never
|
||||
# touches the live service, lock, or data.
|
||||
# --skip-db-check Don't run sqlite3 integrity_check (not recommended)
|
||||
# --keep-previous / --delete-previous What to do with the moved-aside pre-
|
||||
# restore data on a FULLY successful restore (default:
|
||||
# keep -- see it named in the summary, remove it
|
||||
# yourself once you've confirmed the restore is good)
|
||||
# --wait SECONDS How long to wait for Gitea to become healthy
|
||||
# after starting it (default 300)
|
||||
# --quiet Only print warnings and errors
|
||||
# -h, --help
|
||||
#
|
||||
# Exit codes: 0 restore complete (or, with no --yes, archive verified OK)
|
||||
# 1 failed -- refused before touching live data, or the archive
|
||||
# itself is bad
|
||||
# 2 usage error
|
||||
# 3 restore extracted, but the restored database failed its
|
||||
# integrity check -- the service was NOT started; your
|
||||
# previous data is untouched, at the path printed in the
|
||||
# summary
|
||||
# 75 a backup or restore is already running (lock held)
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
CONF_FILE="${GITEA_PODMAN_CONF:-/etc/gitea-podman/gitea-podman.conf}"
|
||||
LOCK_FILE="${GITEA_PODMAN_LOCK:-/run/lock/gitea-podman.lock}"
|
||||
|
||||
ARCHIVE=""; USE_LATEST=0; CONFIRM=0; TARGET_ROOT=""; SKIP_DB_CHECK=0
|
||||
KEEP_PREVIOUS=1; WAIT_SECS=300; QUIET=0
|
||||
|
||||
DATA_ROOT=""; BACKUP_DIR=""; CONTAINER_NAME=""; UNIT_NAME=""
|
||||
CONTAINER_UID=""; CONTAINER_GID=""; HTTP_BIND=""; HTTP_PORT=""; HEALTH_URL=""
|
||||
|
||||
STOPPED_BY_US=0
|
||||
WORKDIR_TMP=""
|
||||
ASIDE_WORK=""; ASIDE_CONFIG=""
|
||||
DB_REL=""
|
||||
|
||||
log() { if (( ! QUIET )); then printf '%s [+] %s\n' "$(date '+%F %T')" "$*"; fi; }
|
||||
warn() { printf '%s [!] %s\n' "$(date '+%F %T')" "$*" >&2; }
|
||||
die() { printf '%s [x] %s\n' "$(date '+%F %T')" "$*" >&2; exit 1; }
|
||||
usage() { sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
conf_get() { [[ -r "$CONF_FILE" ]] || return 0; sed -n "s/^${1}=//p" "$CONF_FILE" | head -n1; }
|
||||
|
||||
ini_get() { # ini_get <file> <section> <key>
|
||||
awk -v sec="$2" -v key="$3" '
|
||||
/^[[:space:]]*[;#]/ { next }
|
||||
/^[[:space:]]*\[/ { cur = $0; gsub(/[][[:space:]]/, "", cur); next }
|
||||
{ k = $0; sub(/[[:space:]]*=.*/, "", k); gsub(/^[[:space:]]+/, "", k)
|
||||
if (cur == sec && k == key) { v = $0; sub(/^[^=]*=[[:space:]]*/, "", v); sub(/[[:space:]]+$/, "", v); print v; exit } }' "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
load_conf() {
|
||||
[[ -f "$CONF_FILE" ]] || die "Config file $CONF_FILE not found -- run setup-gitea-podman.sh first (on a fresh machine, run it BEFORE this script, so there is a service and a UID/GID to restore into)."
|
||||
local owner mode
|
||||
owner="$(stat -c '%u' "$CONF_FILE")"; mode="$(stat -c '%a' "$CONF_FILE")"
|
||||
[[ "$owner" == 0 ]] || die "$CONF_FILE must be owned by root."
|
||||
(( (8#$mode & 8#022) == 0 )) || die "$CONF_FILE must not be writable by group/others."
|
||||
DATA_ROOT="$(conf_get DATA_ROOT)"; BACKUP_DIR="$(conf_get BACKUP_DIR)"
|
||||
CONTAINER_NAME="$(conf_get CONTAINER_NAME)"; UNIT_NAME="$(conf_get UNIT_NAME)"
|
||||
CONTAINER_UID="$(conf_get CONTAINER_UID)"; CONTAINER_GID="$(conf_get CONTAINER_GID)"
|
||||
HTTP_BIND="$(conf_get HTTP_BIND)"; HTTP_PORT="$(conf_get HTTP_PORT)"
|
||||
local k
|
||||
for k in DATA_ROOT BACKUP_DIR CONTAINER_NAME UNIT_NAME CONTAINER_UID CONTAINER_GID HTTP_BIND HTTP_PORT; do
|
||||
[[ -n "${!k}" ]] || die "$k is missing in $CONF_FILE."
|
||||
done
|
||||
[[ "$DATA_ROOT" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "Suspicious DATA_ROOT in $CONF_FILE."
|
||||
local host="$HTTP_BIND"; [[ "$host" != "0.0.0.0" ]] || host="127.0.0.1"
|
||||
HEALTH_URL="http://${host}:${HTTP_PORT}/api/healthz"
|
||||
}
|
||||
|
||||
svc_active() {
|
||||
local s; s="$(systemctl is-active "$UNIT_NAME" 2>/dev/null || true)"
|
||||
case "$s" in active|activating|reloading) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
container_running() { [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" == "true" ]]; }
|
||||
wait_healthy() {
|
||||
local limit="$1" i
|
||||
for (( i = 0; i < limit; i += 2 )); do
|
||||
curl -fsS --max-time 5 -o /dev/null "$HEALTH_URL" 2>/dev/null && return 0
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2329 # invoked via 'trap cleanup EXIT' in main(), not called directly
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
trap - EXIT INT TERM
|
||||
if [[ -n "$WORKDIR_TMP" && -d "$WORKDIR_TMP" ]]; then rm -rf -- "$WORKDIR_TMP"; fi
|
||||
# main() explicitly clears STOPPED_BY_US at every well-handled exit point
|
||||
# (success, the DB-integrity-failure case where staying stopped is the
|
||||
# point, and right after a successful restart) -- so if it's still 1 here,
|
||||
# something unexpected happened between stopping the service and one of
|
||||
# those points, and the safest recovery is to try bringing it back up.
|
||||
if (( STOPPED_BY_US )); then
|
||||
warn "Starting $UNIT_NAME again (best-effort, after an unexpected error)..."
|
||||
if systemctl start "$UNIT_NAME" 2>/dev/null; then
|
||||
STOPPED_BY_US=0
|
||||
else
|
||||
warn "!!! COULD NOT START $UNIT_NAME -- start it manually: systemctl start $UNIT_NAME"
|
||||
fi
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
need_arg() { [[ $# -ge 2 ]] || { warn "Option $1 needs a value (see --help)"; exit 2; }; }
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--latest) USE_LATEST=1; shift ;;
|
||||
--yes) CONFIRM=1; shift ;;
|
||||
--target-root) need_arg "$@"; TARGET_ROOT="$2"; shift 2 ;;
|
||||
--skip-db-check) SKIP_DB_CHECK=1; shift ;;
|
||||
--keep-previous) KEEP_PREVIOUS=1; shift ;;
|
||||
--delete-previous) KEEP_PREVIOUS=0; shift ;;
|
||||
--wait) need_arg "$@"; WAIT_SECS="$2"; shift 2 ;;
|
||||
--quiet) QUIET=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--) shift; [[ -n "${1:-}" ]] && { ARCHIVE="$1"; shift; }; ;;
|
||||
-*) warn "Unknown option: $1 (see --help)"; exit 2 ;;
|
||||
*)
|
||||
[[ -z "$ARCHIVE" ]] || { warn "Only one backup file may be given (see --help)"; exit 2; }
|
||||
ARCHIVE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[[ "$WAIT_SECS" =~ ^[0-9]+$ ]] || { warn "--wait needs a number of seconds"; exit 2; }
|
||||
if (( USE_LATEST )) && [[ -n "$ARCHIVE" ]]; then warn "Give either a backup file or --latest, not both."; exit 2; fi
|
||||
if (( ! USE_LATEST )) && [[ -z "$ARCHIVE" ]]; then warn "Which backup? Give a path, or --latest (see --help)"; exit 2; fi
|
||||
}
|
||||
|
||||
preflight() {
|
||||
[[ $EUID -eq 0 ]] || die "Run this as root (sudo)."
|
||||
local c
|
||||
for c in podman systemctl tar gzip sha256sum flock curl awk sed stat realpath date mktemp; do
|
||||
command -v "$c" >/dev/null 2>&1 || die "Required command not found: $c"
|
||||
done
|
||||
(( SKIP_DB_CHECK )) || command -v sqlite3 >/dev/null 2>&1 \
|
||||
|| die "sqlite3 is needed to verify the restored database (install it, or pass --skip-db-check)."
|
||||
load_conf
|
||||
|
||||
if (( USE_LATEST )); then
|
||||
ARCHIVE="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'gitea-backup-????????T??????Z.tar.gz' 2>/dev/null | sort -r | head -n1)"
|
||||
[[ -n "$ARCHIVE" ]] || die "No backups matching gitea-backup-*.tar.gz found in $BACKUP_DIR."
|
||||
log "Using the newest backup: $(basename -- "$ARCHIVE")"
|
||||
fi
|
||||
ARCHIVE="$(realpath -e -- "$ARCHIVE" 2>/dev/null)" || die "Backup file not found: $ARCHIVE"
|
||||
[[ -r "$ARCHIVE" ]] || die "Cannot read $ARCHIVE."
|
||||
|
||||
if [[ -n "$TARGET_ROOT" ]]; then
|
||||
TARGET_ROOT="$(realpath -m -- "$TARGET_ROOT")"
|
||||
[[ ! -e "$TARGET_ROOT" ]] || die "--target-root $TARGET_ROOT already exists -- a drill restore refuses to reuse or overwrite an existing path. Pick an empty/new path."
|
||||
fi
|
||||
|
||||
systemctl cat "$UNIT_NAME" >/dev/null 2>&1 || [[ -n "$TARGET_ROOT" ]] \
|
||||
|| die "systemd unit $UNIT_NAME not found -- run setup-gitea-podman.sh first."
|
||||
}
|
||||
|
||||
verify_checksum() {
|
||||
local sidecar="${ARCHIVE}.sha256"
|
||||
if [[ -f "$sidecar" ]]; then
|
||||
log "Verifying checksum against $(basename -- "$sidecar")..."
|
||||
( cd -- "$(dirname -- "$ARCHIVE")" && sha256sum --check --status -- "$(basename -- "$sidecar")" ) \
|
||||
|| die "Checksum does NOT match $(basename -- "$sidecar") -- refusing to restore from a file that doesn't match its own sidecar. Nothing was touched."
|
||||
else
|
||||
warn "No .sha256 sidecar next to this archive -- skipping checksum verification (structure/gzip checks below still run)."
|
||||
fi
|
||||
}
|
||||
|
||||
verify_structure() {
|
||||
log "Verifying archive integrity (gzip + tar structure)..."
|
||||
gzip -t -- "$ARCHIVE" || die "gzip integrity test failed on $ARCHIVE -- refusing to restore from it. Nothing was touched."
|
||||
WORKDIR_TMP="$(mktemp -d)"
|
||||
tar -tzf "$ARCHIVE" > "${WORKDIR_TMP}/listing.txt" || die "tar cannot read $ARCHIVE -- refusing to restore from it. Nothing was touched."
|
||||
local m
|
||||
for m in BACKUP-INFO.txt config/app.ini work/.gitea-podman-data; do
|
||||
grep -qxF -- "$m" "${WORKDIR_TMP}/listing.txt" \
|
||||
|| die "$ARCHIVE is missing required member '$m' -- this doesn't look like a complete gitea-podman-backup.sh archive. Nothing was touched."
|
||||
done
|
||||
tar -xzf "$ARCHIVE" -C "$WORKDIR_TMP" --numeric-owner BACKUP-INFO.txt infra config/app.ini 2>/dev/null || true
|
||||
# Same technique the backup script itself uses on the live app.ini: read
|
||||
# [database] straight from the archived one rather than guess a filename
|
||||
# pattern, so this stays correct even if Gitea ever changes the default.
|
||||
local app="${WORKDIR_TMP}/config/app.ini" dbtype dbpath
|
||||
if [[ -f "$app" ]]; then
|
||||
dbtype="$(ini_get "$app" database DB_TYPE)"
|
||||
if [[ "$dbtype" == "sqlite3" ]]; then
|
||||
dbpath="$(ini_get "$app" database PATH)"
|
||||
case "$dbpath" in
|
||||
/var/lib/gitea/*) DB_REL="work/${dbpath#/var/lib/gitea/}" ;;
|
||||
*) warn "Unexpected SQLite path '$dbpath' in the archived app.ini; the database check will be skipped." ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
print_plan() {
|
||||
local info="${WORKDIR_TMP}/BACKUP-INFO.txt" dest="$DATA_ROOT"
|
||||
[[ -n "$TARGET_ROOT" ]] && dest="$TARGET_ROOT (drill -- live data and service are not touched)"
|
||||
cat <<EOF
|
||||
|
||||
============================================================================
|
||||
Restore plan
|
||||
============================================================================
|
||||
Archive $ARCHIVE
|
||||
Checksum $( [[ -f "${ARCHIVE}.sha256" ]] && echo "verified against ${ARCHIVE}.sha256" || echo "no sidecar found -- unverified" )
|
||||
EOF
|
||||
if [[ -f "$info" ]]; then
|
||||
sed -nE 's/^created_utc=(.*)/ Backup made \1/p; s/^gitea_version=(.*)/ Gitea version \1/p; s/^hostname=(.*)/ From host \1/p' "$info"
|
||||
fi
|
||||
cat <<EOF
|
||||
Restore into $dest
|
||||
EOF
|
||||
if [[ -z "$TARGET_ROOT" ]]; then
|
||||
cat <<EOF
|
||||
Current data moved aside to a timestamped directory next to $DATA_ROOT, NOT deleted
|
||||
($( [[ $KEEP_PREVIOUS -eq 1 ]] && echo "kept after a successful restore -- remove it yourself once you've checked things over" || echo "deleted automatically once the restore succeeds and Gitea is healthy" ))
|
||||
EOF
|
||||
fi
|
||||
if (( ! CONFIRM )) && [[ -z "$TARGET_ROOT" ]]; then
|
||||
cat <<EOF
|
||||
|
||||
Nothing has been touched. Re-run with --yes to actually restore.
|
||||
============================================================================
|
||||
EOF
|
||||
else
|
||||
echo "============================================================================"
|
||||
fi
|
||||
}
|
||||
|
||||
do_drill() {
|
||||
log "Extracting into $TARGET_ROOT for inspection (service and live data untouched)..."
|
||||
mkdir -p -- "$TARGET_ROOT"
|
||||
tar -xzf "$ARCHIVE" -C "$TARGET_ROOT" --numeric-owner || die "Extraction into $TARGET_ROOT failed."
|
||||
local rc=0
|
||||
if (( ! SKIP_DB_CHECK )) && [[ -n "$DB_REL" && -f "${TARGET_ROOT}/${DB_REL}" ]]; then
|
||||
local res; res="$(sqlite3 "${TARGET_ROOT}/${DB_REL}" 'PRAGMA integrity_check;' 2>&1 || true)"
|
||||
if [[ "$res" == "ok" ]]; then
|
||||
log "SQLite integrity_check: ok"
|
||||
else
|
||||
warn "SQLite integrity_check FAILED on the extracted database:"
|
||||
printf '%s\n' "$res" | head -n 8 | sed 's/^/ /' >&2
|
||||
rc=3
|
||||
fi
|
||||
fi
|
||||
log "Drill extraction complete: $TARGET_ROOT"
|
||||
log "Nothing on the live system was touched. Remove $TARGET_ROOT yourself when done inspecting it."
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
if svc_active; then
|
||||
log "Stopping $UNIT_NAME (graceful; up to 2.5 min)..."
|
||||
STOPPED_BY_US=1
|
||||
systemctl stop "$UNIT_NAME" || die "Could not stop $UNIT_NAME. Nothing was moved or overwritten."
|
||||
else
|
||||
log "$UNIT_NAME is not running; proceeding."
|
||||
fi
|
||||
if container_running; then
|
||||
die "Container $CONTAINER_NAME is still running after the stop -- refusing to touch live data."
|
||||
fi
|
||||
}
|
||||
|
||||
move_aside_current_data() {
|
||||
local ts; ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
ASIDE_WORK="${DATA_ROOT}/work.before-restore-${ts}"
|
||||
ASIDE_CONFIG="${DATA_ROOT}/config.before-restore-${ts}"
|
||||
log "Moving current data aside (not deleting): $(basename -- "$ASIDE_WORK"), $(basename -- "$ASIDE_CONFIG")"
|
||||
if [[ -d "${DATA_ROOT}/work" ]]; then
|
||||
mv -T -- "${DATA_ROOT}/work" "$ASIDE_WORK" || die "Could not move ${DATA_ROOT}/work aside -- stopping before anything is overwritten."
|
||||
fi
|
||||
if [[ -d "${DATA_ROOT}/config" ]]; then
|
||||
mv -T -- "${DATA_ROOT}/config" "$ASIDE_CONFIG" || die "Could not move ${DATA_ROOT}/config aside -- stopping before anything is overwritten."
|
||||
fi
|
||||
}
|
||||
|
||||
extract_data() {
|
||||
log "Extracting archive into $DATA_ROOT..."
|
||||
mkdir -p -- "$DATA_ROOT"
|
||||
tar -xzf "$ARCHIVE" -C "$DATA_ROOT" --numeric-owner config work \
|
||||
|| die "Extraction failed. Your previous data is untouched at $(basename -- "$ASIDE_WORK") / $(basename -- "$ASIDE_CONFIG")."
|
||||
chown -R "${CONTAINER_UID}:${CONTAINER_GID}" "${DATA_ROOT}/work" "${DATA_ROOT}/config"
|
||||
chmod 750 "${DATA_ROOT}/work"
|
||||
chmod 700 "${DATA_ROOT}/config"
|
||||
find "${DATA_ROOT}/config" -type f -exec chmod 600 {} +
|
||||
}
|
||||
|
||||
check_restored_db() {
|
||||
(( ! SKIP_DB_CHECK )) || { warn "--skip-db-check: not verifying the restored database before starting Gitea."; return 0; }
|
||||
[[ -n "$DB_REL" ]] || { warn "Could not identify the database file in the archive; skipping the integrity check."; return 0; }
|
||||
local path="${DATA_ROOT}/${DB_REL}"
|
||||
[[ -f "$path" ]] || { warn "Expected database at $path was not found after extraction; skipping the integrity check."; return 0; }
|
||||
log "Checking the restored database before starting Gitea..."
|
||||
local res; res="$(sqlite3 "$path" 'PRAGMA integrity_check;' 2>&1 || true)"
|
||||
if [[ "$res" == "ok" ]]; then
|
||||
log "SQLite integrity_check: ok"
|
||||
return 0
|
||||
fi
|
||||
warn "SQLite integrity_check FAILED on the restored database:"
|
||||
printf '%s\n' "$res" | head -n 8 | sed 's/^/ /' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
main() {
|
||||
parse_args "$@"
|
||||
preflight
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
verify_checksum
|
||||
verify_structure
|
||||
|
||||
if [[ -n "$TARGET_ROOT" ]]; then
|
||||
print_plan
|
||||
do_drill
|
||||
fi
|
||||
|
||||
print_plan
|
||||
if (( ! CONFIRM )); then
|
||||
log "Archive verified OK. Add --yes to actually restore."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
stop_service
|
||||
move_aside_current_data
|
||||
extract_data
|
||||
|
||||
if ! check_restored_db; then
|
||||
warn "NOT starting $UNIT_NAME on top of a database that failed its integrity check."
|
||||
warn "Your previous data is intact at:"
|
||||
warn " $ASIDE_WORK"
|
||||
warn " $ASIDE_CONFIG"
|
||||
warn "To go back: stop here, remove what was just extracted, and move those two back into place."
|
||||
STOPPED_BY_US=0 # leaving it stopped is the point; the cleanup trap should not try to start it
|
||||
exit 3
|
||||
fi
|
||||
|
||||
log "Starting $UNIT_NAME..."
|
||||
systemctl start "$UNIT_NAME" || {
|
||||
warn "COULD NOT START $UNIT_NAME after the restore. The restored data is in place at $DATA_ROOT;"
|
||||
warn "your previous data is intact at $ASIDE_WORK / $ASIDE_CONFIG if you need to revert."
|
||||
exit 1
|
||||
}
|
||||
STOPPED_BY_US=0
|
||||
|
||||
local rc=0
|
||||
log "Waiting for Gitea to answer on $HEALTH_URL ..."
|
||||
if wait_healthy "$WAIT_SECS"; then
|
||||
log "Gitea is healthy again."
|
||||
else
|
||||
warn "Gitea did not become healthy within ${WAIT_SECS}s after the restore. Check: journalctl -u $UNIT_NAME"
|
||||
rc=3
|
||||
fi
|
||||
|
||||
if (( rc == 0 )); then
|
||||
if (( KEEP_PREVIOUS )); then
|
||||
log "DONE. Your data before this restore is kept at:"
|
||||
log " $ASIDE_WORK"
|
||||
log " $ASIDE_CONFIG"
|
||||
log "Remove those yourself once you've confirmed the restore looks right."
|
||||
else
|
||||
log "Restore succeeded and Gitea is healthy; removing the pre-restore copy (--delete-previous)..."
|
||||
rm -rf -- "$ASIDE_WORK" "$ASIDE_CONFIG"
|
||||
log "DONE."
|
||||
fi
|
||||
else
|
||||
warn "Restore extracted and started, but needs attention (see above). Your data before this"
|
||||
warn "restore is still kept at $ASIDE_WORK / $ASIDE_CONFIG regardless of --delete-previous."
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
if [[ "${GITEA_PODMAN_RESTORE_RELAUNCHED:-}" != "1" ]]; then
|
||||
for a in "$@"; do [[ "$a" == "-h" || "$a" == "--help" ]] && { usage; exit 0; }; done
|
||||
mkdir -p -- "$(dirname -- "$LOCK_FILE")"
|
||||
export GITEA_PODMAN_RESTORE_RELAUNCHED=1
|
||||
rc=0
|
||||
flock -n -E 75 "$LOCK_FILE" "$0" "$@" || rc=$?
|
||||
[[ $rc -eq 75 ]] && warn "A backup or restore is already running (lock: $LOCK_FILE)."
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,659 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# setup-gitea-podman.sh
|
||||
#
|
||||
# Runs Gitea (https://about.gitea.com) as a rootless-IMAGE container under
|
||||
# ROOTFUL Podman + systemd (Quadlet), on a fresh Linux box. This is the
|
||||
# Podman counterpart to setup-git-server.sh; read that script's header for
|
||||
# the overall goals (GitHub-style SSH access, push-to-create). The container
|
||||
# architecture differs in one important way: there is no host sshd involved.
|
||||
# Gitea's OWN built-in SSH server runs inside the container and is published
|
||||
# to the host, so there are no OS user accounts to manage -- SSH keys are
|
||||
# tied to Gitea accounts in its database, exactly like GitHub.
|
||||
#
|
||||
# All persistent data lives OUTSIDE the container, under --data-root
|
||||
# (default /srv/gitea), in two directories bind-mounted in:
|
||||
# <data-root>/work -> /var/lib/gitea (repos, sqlite db, LFS, avatars, SSH host keys)
|
||||
# <data-root>/config -> /etc/gitea (app.ini -- also holds the secrets the
|
||||
# database's encrypted columns depend on)
|
||||
# The container itself is disposable: `podman rm` it, or even reinstall this
|
||||
# script against the same --data-root, and nothing is lost. Use
|
||||
# gitea-podman-backup.sh / gitea-podman-restore.sh to back up and restore
|
||||
# that data; this script only sets the container up.
|
||||
#
|
||||
# Requires: a Podman new enough to generate systemd units from Quadlet
|
||||
# (.container) files (Podman >= 4.4) -- this is checked, not assumed.
|
||||
#
|
||||
# Supported distros: Arch, Debian, Ubuntu, Fedora (and close derivatives).
|
||||
# Must be run as root on a machine where systemd is actually running (a VM
|
||||
# or bare metal -- not a plain Docker container). Written for a first-time
|
||||
# install; re-running it over an existing --data-root reuses the existing
|
||||
# secrets and accounts and just brings the container up to date.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./setup-gitea-podman.sh [options]
|
||||
#
|
||||
# Run with --help for the full list of options.
|
||||
|
||||
set -euo pipefail
|
||||
umask 022
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Defaults (overridable via env vars, then via flags below)
|
||||
# ----------------------------------------------------------------------------
|
||||
GITEA_DATA_ROOT="${GITEA_DATA_ROOT:-/srv/gitea}"
|
||||
GITEA_BACKUP_DIR="${GITEA_BACKUP_DIR:-/srv/gitea-backups}"
|
||||
GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}"
|
||||
GITEA_SSH_PORT="${GITEA_SSH_PORT:-22}"
|
||||
GITEA_DOMAIN="${GITEA_DOMAIN:-}"
|
||||
GITEA_VERSION="${GITEA_VERSION:-latest}"
|
||||
GITEA_DISABLE_REGISTRATION="${GITEA_DISABLE_REGISTRATION:-true}"
|
||||
GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-admin}"
|
||||
GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-admin@example.com}"
|
||||
GITEA_ADMIN_PASSWORD="${GITEA_ADMIN_PASSWORD:-}"
|
||||
GITEA_CONTAINER_UID="${GITEA_CONTAINER_UID:-1000}"
|
||||
GITEA_CONTAINER_GID="${GITEA_CONTAINER_GID:-1000}"
|
||||
|
||||
# Fixed by the image; not meant to be tuned per-install.
|
||||
readonly SSH_LISTEN_PORT=2222
|
||||
readonly CONTAINER_NAME="gitea"
|
||||
readonly UNIT_NAME="gitea.service"
|
||||
readonly QUADLET_DIR="/etc/containers/systemd"
|
||||
readonly QUADLET_FILE="${QUADLET_DIR}/gitea.container"
|
||||
readonly CONF_DIR="/etc/gitea-podman"
|
||||
readonly CONF_FILE="${CONF_DIR}/gitea-podman.conf"
|
||||
|
||||
# Filled in while the script runs
|
||||
DISTRO_FAMILY=""
|
||||
IMAGE=""
|
||||
GITEA_INSTALLED_VERSION=""
|
||||
ADMIN_CREATED="false"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 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; }
|
||||
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: sudo ./setup-gitea-podman.sh [options]
|
||||
|
||||
Runs Gitea under rootful Podman + systemd (Quadlet), with all data kept
|
||||
in a host directory separate from the container.
|
||||
|
||||
Options:
|
||||
--data-root <dir> Where repos/db/config live (default: /srv/gitea)
|
||||
--backup-dir <dir> Recorded for the backup script (default: /srv/gitea-backups)
|
||||
--domain <host> Hostname/IP used in clone URLs (default: auto-detected)
|
||||
--http-port <port> Web UI port on the host (default: 3000)
|
||||
--ssh-port <port> SSH port on the host (default: 22)
|
||||
--version <tag> Gitea version, e.g. 1.27.3 (default: latest release)
|
||||
--admin-user <name> Initial admin username (default: admin)
|
||||
--admin-email <email> Initial admin email (default: admin@example.com)
|
||||
--admin-password <pass> Initial admin password, 8+ chars (default: randomly generated)
|
||||
--allow-registration Let anyone sign up from the web UI (default: off)
|
||||
--uid <uid> --gid <gid> UID:GID the container runs as (default: 1000:1000,
|
||||
matches the official rootless image -- only change this
|
||||
if you know the image has changed)
|
||||
-h, --help Show this help and exit
|
||||
|
||||
Every option can also be set as an environment variable of the same
|
||||
name in caps with a GITEA_ prefix, e.g.:
|
||||
GITEA_DOMAIN=git.example.com sudo -E ./setup-gitea-podman.sh
|
||||
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."
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Argument parsing + validation
|
||||
# ----------------------------------------------------------------------------
|
||||
need_arg() { [[ $# -ge 2 ]] || error_exit "Option $1 needs a value (see --help)"; }
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--data-root) need_arg "$@"; GITEA_DATA_ROOT="$2"; shift 2 ;;
|
||||
--backup-dir) need_arg "$@"; GITEA_BACKUP_DIR="$2"; shift 2 ;;
|
||||
--domain) need_arg "$@"; GITEA_DOMAIN="$2"; shift 2 ;;
|
||||
--http-port) need_arg "$@"; GITEA_HTTP_PORT="$2"; shift 2 ;;
|
||||
--ssh-port) need_arg "$@"; GITEA_SSH_PORT="$2"; shift 2 ;;
|
||||
--version) need_arg "$@"; GITEA_VERSION="$2"; shift 2 ;;
|
||||
--admin-user) need_arg "$@"; GITEA_ADMIN_USER="$2"; shift 2 ;;
|
||||
--admin-email) need_arg "$@"; GITEA_ADMIN_EMAIL="$2"; shift 2 ;;
|
||||
--admin-password) need_arg "$@"; GITEA_ADMIN_PASSWORD="$2"; shift 2 ;;
|
||||
--allow-registration) GITEA_DISABLE_REGISTRATION="false"; shift ;;
|
||||
--uid) need_arg "$@"; GITEA_CONTAINER_UID="$2"; shift 2 ;;
|
||||
--gid) need_arg "$@"; GITEA_CONTAINER_GID="$2"; shift 2 ;;
|
||||
-h|--help) print_help; exit 0 ;;
|
||||
*) error_exit "Unknown option: $1 (see --help)" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
validate_port() {
|
||||
if [[ ! "$1" =~ ^[0-9]+$ ]] || (( 10#$1 < 1 || 10#$1 > 65535 )); then
|
||||
error_exit "Invalid $2: '$1' (expected a port number 1-65535)"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_settings() {
|
||||
validate_port "$GITEA_HTTP_PORT" "HTTP port"
|
||||
validate_port "$GITEA_SSH_PORT" "SSH port"
|
||||
[[ "$GITEA_HTTP_PORT" != "$SSH_LISTEN_PORT" ]] || true # no conflict possible: different sockets
|
||||
[[ "$GITEA_ADMIN_USER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|
||||
|| error_exit "Invalid admin username '${GITEA_ADMIN_USER}' (letters, digits, '.', '_' and '-' only)."
|
||||
[[ "$GITEA_ADMIN_EMAIL" == ?*@?* ]] \
|
||||
|| error_exit "Invalid admin email '${GITEA_ADMIN_EMAIL}'."
|
||||
(( ${#GITEA_ADMIN_PASSWORD} >= 8 )) \
|
||||
|| error_exit "The admin password must be at least 8 characters (Gitea's default minimum)."
|
||||
[[ "$GITEA_CONTAINER_UID" =~ ^[0-9]+$ && "$GITEA_CONTAINER_GID" =~ ^[0-9]+$ ]] \
|
||||
|| error_exit "--uid/--gid must be numeric."
|
||||
[[ "$GITEA_DATA_ROOT" == /* ]] || error_exit "--data-root must be an absolute path."
|
||||
[[ "$GITEA_BACKUP_DIR" == /* ]] || error_exit "--backup-dir must be an absolute path."
|
||||
local rd rb
|
||||
rd="$(realpath -m -- "$GITEA_DATA_ROOT")"; rb="$(realpath -m -- "$GITEA_BACKUP_DIR")"
|
||||
case "$rb/" in "$rd/"*) error_exit "--backup-dir is inside --data-root; they must be separate." ;; esac
|
||||
case "$rd/" in "$rb/"*) error_exit "--data-root is inside --backup-dir; they must be separate." ;; esac
|
||||
GITEA_DATA_ROOT="$rd"; GITEA_BACKUP_DIR="$rb"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Detection helpers
|
||||
# ----------------------------------------------------------------------------
|
||||
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
|
||||
log "Detected ${PRETTY_NAME:-${ID:-unknown}} -> using ${DISTRO_FAMILY} package family."
|
||||
}
|
||||
|
||||
detect_domain() {
|
||||
local ip=""
|
||||
ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '/src/{for(i=1;i<=NF;i++) if ($i=="src") print $(i+1)}') || true
|
||||
if [[ -z "$ip" ]]; then ip=$(hostname -I 2>/dev/null | awk '{print $1}') || true; fi
|
||||
if [[ -n "$ip" ]]; then echo "$ip"; return; fi
|
||||
if [[ -r /etc/hostname ]]; then local h=""; h=$(< /etc/hostname); [[ -n "$h" ]] && { echo "$h"; return; }; fi
|
||||
echo "localhost"
|
||||
}
|
||||
|
||||
generate_password() { tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 20 || true; }
|
||||
|
||||
get_latest_version() {
|
||||
local latest="" json=""
|
||||
json=$(curl -fsSL --max-time 20 https://dl.gitea.com/gitea/version.json 2>/dev/null) || true
|
||||
latest=$(printf '%s' "$json" | tr -d '\n' | awk '
|
||||
{ i = index($0, "\"latest\"")
|
||||
if (i && match(substr($0, i), /"version"[[:space:]]*:[[:space:]]*"v?[0-9][^"]*"/)) {
|
||||
v = substr($0, i + RSTART - 1, RLENGTH)
|
||||
sub(/^"version"[[:space:]]*:[[:space:]]*"v?/, "", v); sub(/"$/, "", v); print v } }') || true
|
||||
if [[ -z "$latest" ]]; then
|
||||
latest=$(curl -fsSL --max-time 20 https://api.github.com/repos/go-gitea/gitea/releases/latest 2>/dev/null \
|
||||
| sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/p' | head -n1) || true
|
||||
fi
|
||||
echo "$latest"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Install steps
|
||||
# ----------------------------------------------------------------------------
|
||||
install_podman() {
|
||||
log "Installing Podman..."
|
||||
case "$DISTRO_FAMILY" in
|
||||
debian)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y podman curl ca-certificates
|
||||
;;
|
||||
fedora)
|
||||
dnf install -y podman curl ca-certificates
|
||||
;;
|
||||
arch)
|
||||
pacman -Syu --noconfirm --needed podman curl ca-certificates
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_quadlet() {
|
||||
log "Checking for Quadlet support (Podman >= 4.4)..."
|
||||
command -v podman &>/dev/null || error_exit "podman was not found on PATH after installation."
|
||||
local pv gen=""
|
||||
pv="$(podman --version 2>/dev/null | awk '{print $3}')"
|
||||
for gen in /usr/lib/systemd/system-generators/podman-system-generator \
|
||||
/usr/libexec/podman/podman-system-generator; do
|
||||
[[ -x "$gen" ]] && break
|
||||
gen=""
|
||||
done
|
||||
if [[ -z "$gen" ]]; then
|
||||
error_exit "Podman ${pv:-unknown} does not ship the Quadlet systemd generator (needs Podman >= 4.4). \
|
||||
On Debian this usually means bookworm's default repo is too old: enable bookworm-backports \
|
||||
(echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/sources.list.d/backports.list \
|
||||
&& apt-get update && apt-get install -y -t bookworm-backports podman), then re-run this script."
|
||||
fi
|
||||
# A dry run smoke-tests the generator itself, not just its presence.
|
||||
local tmp; tmp="$(mktemp -d)"
|
||||
cat > "${tmp}/quadlet-smoketest.container" <<'EOF'
|
||||
[Container]
|
||||
Image=localhost/nonexistent:latest
|
||||
EOF
|
||||
if ! QUADLET_UNIT_DIRS="$tmp" "$gen" --dryrun "${tmp}/out" &>/dev/null; then
|
||||
rm -rf -- "$tmp"
|
||||
error_exit "The Quadlet generator (${gen}) did not run successfully. Try 'apt/dnf/pacman upgrade podman'."
|
||||
fi
|
||||
rm -rf -- "$tmp"
|
||||
log "Podman ${pv:-?} with a working Quadlet generator found."
|
||||
}
|
||||
|
||||
resolve_image() {
|
||||
local version
|
||||
if [[ "$GITEA_VERSION" == "latest" ]]; then
|
||||
log "Looking up the latest Gitea release..."
|
||||
version="$(get_latest_version)"
|
||||
[[ -n "$version" ]] || error_exit "Could not determine the latest Gitea version (dl.gitea.com and the GitHub API were both unreachable or rate-limited). Re-run with --version <x.y.z>."
|
||||
else
|
||||
version="${GITEA_VERSION#v}"
|
||||
fi
|
||||
IMAGE="docker.io/gitea/gitea:${version}-rootless"
|
||||
GITEA_INSTALLED_VERSION="$version"
|
||||
log "Using image ${IMAGE}"
|
||||
}
|
||||
|
||||
pull_image() {
|
||||
log "Pulling ${IMAGE} (this can take a while on first run)..."
|
||||
podman pull -q "$IMAGE" \
|
||||
|| error_exit "Could not pull ${IMAGE}. Check network access to docker.io, and that --version is a real Gitea release (rootless images have shipped since Gitea 1.14)."
|
||||
podman run --rm --user "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" --entrypoint gitea "$IMAGE" --version >/dev/null 2>&1 \
|
||||
|| error_exit "The pulled image did not run 'gitea --version' successfully."
|
||||
}
|
||||
|
||||
setup_directories() {
|
||||
log "Setting up ${GITEA_DATA_ROOT} ..."
|
||||
local work="${GITEA_DATA_ROOT}/work" conf="${GITEA_DATA_ROOT}/config"
|
||||
mkdir -p -- "$work" "$conf"
|
||||
# The container runs as this UID with ALL capabilities dropped (no DAC_OVERRIDE),
|
||||
# so it is subject to ordinary Unix permission checks like anyone else: these
|
||||
# directories must actually be owned by that UID, not just root-writable.
|
||||
chown -R "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "$work"
|
||||
chmod 750 "$work"
|
||||
chown "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "$conf"
|
||||
chmod 700 "$conf"
|
||||
: > "${work}/.gitea-podman-data" # marker the backup/restore scripts check for
|
||||
chown "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "${work}/.gitea-podman-data"
|
||||
|
||||
if [[ ! -d "$GITEA_BACKUP_DIR" ]]; then
|
||||
mkdir -p -- "$GITEA_BACKUP_DIR"
|
||||
chmod 0700 -- "$GITEA_BACKUP_DIR"
|
||||
log "Created backup directory ${GITEA_BACKUP_DIR} (nothing is written there by this script)."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- app.ini -----------------------------------------------------------------
|
||||
|
||||
config_value() {
|
||||
local key="$1" file="${GITEA_DATA_ROOT}/config/app.ini"
|
||||
[[ -r "$file" ]] || return 0
|
||||
sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*\$/\\1/p" "$file" | head -n1
|
||||
}
|
||||
|
||||
secret_for() { # usage: secret_for <app.ini key> <'gitea generate secret' type>
|
||||
local value=""
|
||||
value="$(config_value "$1")"
|
||||
if [[ -z "$value" ]]; then
|
||||
value="$(podman run --rm --user "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" --entrypoint gitea "$IMAGE" generate secret "$2")" \
|
||||
|| error_exit "'gitea generate secret $2' failed."
|
||||
fi
|
||||
[[ -n "$value" ]] || error_exit "Got an empty value for $1."
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
write_config() {
|
||||
local conf="${GITEA_DATA_ROOT}/config"
|
||||
local app="${conf}/app.ini"
|
||||
local secret_key internal_token lfs_jwt_secret oauth2_jwt_secret
|
||||
|
||||
if [[ -f "$app" ]]; then
|
||||
cp -p -- "$app" "${app}.bak.$(date +%s)"
|
||||
warn "Existing ${app} found; backed it up (its secrets are kept, the rest is rewritten)."
|
||||
fi
|
||||
|
||||
log "Generating secrets and writing ${app}..."
|
||||
# Same reasoning as the host script: the running container is not allowed to
|
||||
# write back to app.ini (it is not even its own file -- see setup_directories),
|
||||
# so every secret Gitea would otherwise generate itself on first start has to
|
||||
# already be here, or startup aborts trying to save them.
|
||||
secret_key="$(secret_for SECRET_KEY SECRET_KEY)"
|
||||
internal_token="$(secret_for INTERNAL_TOKEN INTERNAL_TOKEN)"
|
||||
lfs_jwt_secret="$(secret_for LFS_JWT_SECRET JWT_SECRET)"
|
||||
oauth2_jwt_secret="$(secret_for JWT_SECRET JWT_SECRET)"
|
||||
|
||||
: > "$app"
|
||||
chown "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" "$app"
|
||||
chmod 600 "$app"
|
||||
|
||||
cat > "$app" <<EOF
|
||||
APP_NAME = Gitea
|
||||
RUN_MODE = prod
|
||||
RUN_USER = git
|
||||
|
||||
[server]
|
||||
PROTOCOL = http
|
||||
DOMAIN = ${GITEA_DOMAIN}
|
||||
HTTP_ADDR = 0.0.0.0
|
||||
HTTP_PORT = 3000
|
||||
ROOT_URL = http://${GITEA_DOMAIN}:${GITEA_HTTP_PORT}/
|
||||
APP_DATA_PATH = /var/lib/gitea/data
|
||||
DISABLE_SSH = false
|
||||
START_SSH_SERVER = true
|
||||
SSH_DOMAIN = ${GITEA_DOMAIN}
|
||||
; SSH_PORT is what's shown in clone URLs (the host-published port); SSH_LISTEN_PORT
|
||||
; is what Gitea's built-in SSH server actually binds to INSIDE the container -- fixed
|
||||
; at a non-privileged port, mapped to the outside via the Quadlet unit's PublishPort.
|
||||
SSH_PORT = ${GITEA_SSH_PORT}
|
||||
SSH_LISTEN_PORT = ${SSH_LISTEN_PORT}
|
||||
LFS_START_SERVER = true
|
||||
LFS_JWT_SECRET = ${lfs_jwt_secret}
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = /var/lib/gitea/data/gitea.db
|
||||
|
||||
[repository]
|
||||
ROOT = /var/lib/gitea/git/gitea-repositories
|
||||
DEFAULT_BRANCH = main
|
||||
ENABLE_PUSH_CREATE_USER = true
|
||||
ENABLE_PUSH_CREATE_ORG = true
|
||||
DEFAULT_PUSH_CREATE_PRIVATE = true
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY = ${secret_key}
|
||||
INTERNAL_TOKEN = ${internal_token}
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = ${oauth2_jwt_secret}
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = ${GITEA_DISABLE_REGISTRATION}
|
||||
REQUIRE_SIGNIN_VIEW = true
|
||||
DEFAULT_KEEP_EMAIL_PRIVATE = true
|
||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||
|
||||
[log]
|
||||
; Console, not a file under the data volume: 'journalctl -u gitea' is the log,
|
||||
; which keeps log rotation the host's job (journald) instead of ours.
|
||||
MODE = console
|
||||
LEVEL = info
|
||||
EOF
|
||||
}
|
||||
|
||||
# --- one-shot container runs (init/admin) ------------------------------------
|
||||
|
||||
run_oneshot() {
|
||||
podman run --rm \
|
||||
--user "${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}" \
|
||||
--cap-drop=all --security-opt=no-new-privileges \
|
||||
-v "${GITEA_DATA_ROOT}/work:/var/lib/gitea:Z" \
|
||||
-v "${GITEA_DATA_ROOT}/config:/etc/gitea:Z" \
|
||||
"$IMAGE" "$@"
|
||||
}
|
||||
|
||||
init_database() {
|
||||
if systemctl is-active --quiet "$UNIT_NAME" 2>/dev/null; then
|
||||
systemctl stop "$UNIT_NAME"
|
||||
fi
|
||||
log "Initialising the database..."
|
||||
# /usr/local/bin/gitea in this image is a wrapper that injects "-c
|
||||
# $GITEA_APP_INI" for us, so the bare subcommand is all that's needed.
|
||||
run_oneshot gitea migrate || error_exit "'gitea migrate' failed -- see the error above."
|
||||
}
|
||||
|
||||
admin_user_exists() {
|
||||
run_oneshot gitea admin user list 2>/dev/null \
|
||||
| awk -v u="$GITEA_ADMIN_USER" 'NR > 1 && tolower($2) == tolower(u) { found = 1 } END { exit !found }'
|
||||
}
|
||||
|
||||
create_admin_user() {
|
||||
log "Creating admin user '${GITEA_ADMIN_USER}'..."
|
||||
if run_oneshot gitea admin user create \
|
||||
--username "$GITEA_ADMIN_USER" \
|
||||
--password "$GITEA_ADMIN_PASSWORD" \
|
||||
--email "$GITEA_ADMIN_EMAIL" \
|
||||
--admin --must-change-password=false; then
|
||||
ADMIN_CREATED="true"
|
||||
log "Admin user created."
|
||||
elif admin_user_exists; then
|
||||
warn "User '${GITEA_ADMIN_USER}' already exists (previous run?); leaving it and its password unchanged."
|
||||
else
|
||||
error_exit "Could not create the admin user -- see the error above."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Quadlet / systemd ---------------------------------------------------------
|
||||
|
||||
write_quadlet_unit() {
|
||||
log "Writing Quadlet unit ${QUADLET_FILE}..."
|
||||
mkdir -p -- "$QUADLET_DIR"
|
||||
cat > "$QUADLET_FILE" <<EOF
|
||||
# Generated by setup-gitea-podman.sh. PodmanArgs below gives Gitea up to 90s
|
||||
# to shut down cleanly (finish requests, close the sqlite connection) before
|
||||
# Podman SIGKILLs it -- matters most when the backup script stops this unit.
|
||||
[Unit]
|
||||
Description=Gitea (Podman)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=${IMAGE}
|
||||
ContainerName=${CONTAINER_NAME}
|
||||
User=${GITEA_CONTAINER_UID}:${GITEA_CONTAINER_GID}
|
||||
Volume=${GITEA_DATA_ROOT}/work:/var/lib/gitea:Z
|
||||
Volume=${GITEA_DATA_ROOT}/config:/etc/gitea:Z
|
||||
PublishPort=0.0.0.0:${GITEA_HTTP_PORT}:3000
|
||||
PublishPort=0.0.0.0:${GITEA_SSH_PORT}:${SSH_LISTEN_PORT}
|
||||
DropCapability=ALL
|
||||
NoNewPrivileges=true
|
||||
PodmanArgs=--stop-timeout=90
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=300
|
||||
TimeoutStopSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
start_service() {
|
||||
log "Starting ${UNIT_NAME}..."
|
||||
systemctl enable "$UNIT_NAME"
|
||||
systemctl restart "$UNIT_NAME"
|
||||
}
|
||||
|
||||
wait_for_gitea() {
|
||||
log "Waiting for Gitea to come up..."
|
||||
local host="$GITEA_DOMAIN"
|
||||
[[ "$host" != "0.0.0.0" ]] || host="127.0.0.1"
|
||||
local _
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS -o /dev/null "http://${host}:${GITEA_HTTP_PORT}/api/healthz" 2>/dev/null; then
|
||||
log "Gitea is up."
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
warn "Gitea did not answer within 2 minutes. Last log lines:"
|
||||
journalctl -u "$UNIT_NAME" -n 40 --no-pager >&2 || true
|
||||
error_exit "Gitea failed to start -- fix the problem shown above (full log: journalctl -u $UNIT_NAME)."
|
||||
}
|
||||
|
||||
write_adduser_helper() {
|
||||
cat > /usr/local/bin/gitea-podman-adduser <<EOF
|
||||
#!/usr/bin/env bash
|
||||
# Convenience wrapper around 'gitea admin user create', run inside the LIVE container.
|
||||
# Examples:
|
||||
# sudo gitea-podman-adduser --username alice --email alice@example.com --password 'TempPass123!'
|
||||
# sudo gitea-podman-adduser --username bob --email bob@example.com --password 'TempPass123!' --admin
|
||||
[[ \$EUID -eq 0 ]] || exec sudo "\$0" "\$@"
|
||||
exec podman exec -i ${CONTAINER_NAME} gitea admin user create "\$@"
|
||||
EOF
|
||||
chmod 755 /usr/local/bin/gitea-podman-adduser
|
||||
}
|
||||
|
||||
configure_firewall() {
|
||||
if command -v ufw &>/dev/null && [[ "$(ufw status 2>/dev/null)" == *"Status: active"* ]]; then
|
||||
log "Opening ports in ufw..."
|
||||
ufw allow "${GITEA_SSH_PORT}/tcp" || true
|
||||
ufw allow "${GITEA_HTTP_PORT}/tcp" || true
|
||||
elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then
|
||||
log "Opening ports in firewalld..."
|
||||
firewall-cmd --permanent --add-port="${GITEA_SSH_PORT}/tcp" || true
|
||||
firewall-cmd --permanent --add-port="${GITEA_HTTP_PORT}/tcp" || true
|
||||
firewall-cmd --reload || true
|
||||
else
|
||||
log "No active firewall manager (ufw/firewalld) found; nothing to configure there."
|
||||
fi
|
||||
}
|
||||
|
||||
write_conf_file() {
|
||||
# The single source of truth gitea-podman-backup.sh / -restore.sh read from --
|
||||
# keeps them from duplicating (and drifting from) what this script set up.
|
||||
mkdir -p -- "$CONF_DIR"
|
||||
chmod 0700 -- "$CONF_DIR"
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
DATA_ROOT=${GITEA_DATA_ROOT}
|
||||
BACKUP_DIR=${GITEA_BACKUP_DIR}
|
||||
CONTAINER_NAME=${CONTAINER_NAME}
|
||||
UNIT_NAME=${UNIT_NAME}
|
||||
IMAGE=${IMAGE}
|
||||
CONTAINER_UID=${GITEA_CONTAINER_UID}
|
||||
CONTAINER_GID=${GITEA_CONTAINER_GID}
|
||||
HTTP_BIND=0.0.0.0
|
||||
HTTP_PORT=${GITEA_HTTP_PORT}
|
||||
SSH_BIND=0.0.0.0
|
||||
SSH_PORT=${GITEA_SSH_PORT}
|
||||
DOMAIN=${GITEA_DOMAIN}
|
||||
EOF
|
||||
chmod 600 "$CONF_FILE"
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
local registration="enabled" admin_password_line
|
||||
[[ "$GITEA_DISABLE_REGISTRATION" == "true" ]] && registration="disabled"
|
||||
if [[ "$ADMIN_CREATED" == "true" ]]; then
|
||||
admin_password_line="${GITEA_ADMIN_PASSWORD} (log in and change/rotate it)"
|
||||
else
|
||||
admin_password_line="(account already existed -- password left unchanged)"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
============================================================================
|
||||
Gitea ${GITEA_INSTALLED_VERSION} is running in Podman (image ${IMAGE})
|
||||
============================================================================
|
||||
|
||||
Web UI http://${GITEA_DOMAIN}:${GITEA_HTTP_PORT}/
|
||||
Admin user ${GITEA_ADMIN_USER}
|
||||
Admin email ${GITEA_ADMIN_EMAIL}
|
||||
Admin password ${admin_password_line}
|
||||
|
||||
Clone and push over SSH, exactly like GitHub -- there is no separate SSH
|
||||
key file to install on the server: add your key in the web UI (Settings ->
|
||||
SSH / GPG Keys) and Gitea's own SSH server (inside the container) handles
|
||||
the rest.
|
||||
|
||||
git@${GITEA_DOMAIN}:<username>/<repo>.git $( [[ "$GITEA_SSH_PORT" != 22 ]] && echo "(add: -p ${GITEA_SSH_PORT}, or set it in your SSH config)" )
|
||||
|
||||
A repo doesn't need to be created first -- the first push creates it
|
||||
(as a private repo by default):
|
||||
|
||||
mkdir myproject && cd myproject && git init
|
||||
git commit --allow-empty -m "init"
|
||||
git remote add origin git@${GITEA_DOMAIN}:${GITEA_ADMIN_USER}/myproject.git
|
||||
git push -u origin main
|
||||
|
||||
Add more accounts any time with:
|
||||
|
||||
sudo gitea-podman-adduser --username <name> --email <email> --password '<temp-pass>'
|
||||
|
||||
Data lives OUTSIDE the container and survives 'podman rm'/reinstalls:
|
||||
Work dir (repos, db, LFS, SSH host keys): ${GITEA_DATA_ROOT}/work
|
||||
Config (app.ini + secrets): ${GITEA_DATA_ROOT}/config
|
||||
Recorded backup directory: ${GITEA_BACKUP_DIR} (nothing written yet --
|
||||
run gitea-podman-backup.sh to take a backup)
|
||||
|
||||
Useful commands:
|
||||
systemctl status ${UNIT_NAME}
|
||||
systemctl restart ${UNIT_NAME}
|
||||
journalctl -u ${UNIT_NAME} -f
|
||||
podman exec -it ${CONTAINER_NAME} sh
|
||||
|
||||
Public sign-up from the web UI: ${registration} (see [service] DISABLE_REGISTRATION in app.ini)
|
||||
|
||||
This is plain HTTP -- fine on a LAN or over a VPN. For anything reachable
|
||||
from the open internet: put it behind a reverse proxy (nginx/Caddy) with a
|
||||
real TLS certificate, and double check any cloud provider firewall/security
|
||||
group rules in addition to the local firewall this script already configured.
|
||||
|
||||
Back this up regularly -- see gitea-podman-backup.sh. Data loss protection
|
||||
only works if you actually run it (a systemd timer is the easiest way).
|
||||
============================================================================
|
||||
EOF
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Main
|
||||
# ----------------------------------------------------------------------------
|
||||
main() {
|
||||
require_root
|
||||
: "${GITEA_ADMIN_PASSWORD:=$(generate_password)}"
|
||||
validate_settings
|
||||
detect_distro_family
|
||||
|
||||
install_podman
|
||||
require_quadlet
|
||||
: "${GITEA_DOMAIN:=$(detect_domain)}"
|
||||
|
||||
resolve_image
|
||||
pull_image
|
||||
setup_directories
|
||||
write_config
|
||||
init_database
|
||||
create_admin_user
|
||||
write_quadlet_unit
|
||||
start_service
|
||||
wait_for_gitea
|
||||
write_adduser_helper
|
||||
configure_firewall
|
||||
write_conf_file
|
||||
print_summary
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
parse_args "$@"
|
||||
main
|
||||
fi
|
||||
Reference in new issue
Block a user