CI Pipeline / lint (push) Failing after 55s
CI Pipeline / canary (push) Successful in 6s
CI Pipeline / best-practices (push) Successful in 5s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / gitea-offline (push) Successful in 39s
CI Pipeline / required-checks (push) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Failing after 22s
CI Pipeline / canary (pull_request) Successful in 5s
CI Pipeline / best-practices (pull_request) Successful in 5s
CI Pipeline / documentation (pull_request) Successful in 5s
CI Pipeline / gitea-offline (pull_request) Successful in 39s
CI Pipeline / required-checks (pull_request) Skipped
Track branch policy, labels, milestones, and wiki content as declarative state with a read-only-by-default provisioner. Harden off-disk backup publication and add a rootless, networkless restore drill that validates SQLite, application data, and database-declared bare repositories. Run offline contracts in CI, pin audited dependencies, document operations, and bump Rune to 0.43.0.
196 lines
8.1 KiB
Bash
Executable File
196 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
BACKUP_MOUNT=${BACKUP_MOUNT:-/media/pi/Foundry}
|
|
BACKUP_DIR=${BACKUP_DIR:-$BACKUP_MOUNT/backups/gitea}
|
|
RETENTION_DAYS=${RETENTION_DAYS:-30}
|
|
LOCK_FILE=${LOCK_FILE:-/run/lock/rune-gitea-backup.lock}
|
|
WORK_ROOT=${WORK_ROOT:-/var/lib/gitea}
|
|
BACKUP_OWNER=${BACKUP_OWNER-pi:pi}
|
|
GITEA_RUN_USER=${GITEA_RUN_USER-git}
|
|
GITEA_BIN=${GITEA_BIN:-/usr/local/bin/gitea}
|
|
GITEA_CONFIG=${GITEA_CONFIG:-/etc/gitea/app.ini}
|
|
GITEA_WORK_PATH=${GITEA_WORK_PATH:-/var/lib/gitea}
|
|
SYSTEMCTL_BIN=${SYSTEMCTL_BIN:-systemctl}
|
|
CURL_BIN=${CURL_BIN:-curl}
|
|
SHA256SUM_BIN=${SHA256SUM_BIN:-sha256sum}
|
|
REALPATH_BIN=${REALPATH_BIN:-realpath}
|
|
MOUNTPOINT_BIN=${MOUNTPOINT_BIN:-mountpoint}
|
|
FINDMNT_BIN=${FINDMNT_BIN:-findmnt}
|
|
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
|
ARCHIVE="gitea-dump-${STAMP}.tar.gz"
|
|
|
|
die() { printf 'Gitea backup failed: %s\n' "$*" >&2; exit 1; }
|
|
|
|
# Check both lexical containment and symlink-aware containment before touching
|
|
# the destination. The configured mount itself must be mounted on a device
|
|
# distinct from / so an absent off-disk volume can never become a local folder.
|
|
mount_lex=$("$REALPATH_BIN" -m -s -- "$BACKUP_MOUNT") || die "cannot normalize BACKUP_MOUNT"
|
|
backup_lex=$("$REALPATH_BIN" -m -s -- "$BACKUP_DIR") || die "cannot normalize BACKUP_DIR"
|
|
mount_real=$("$REALPATH_BIN" -m -- "$BACKUP_MOUNT") || die "cannot resolve BACKUP_MOUNT"
|
|
backup_real=$("$REALPATH_BIN" -m -- "$BACKUP_DIR") || die "cannot resolve BACKUP_DIR"
|
|
case "$backup_lex" in "$mount_lex"|"$mount_lex"/*) ;; *) die "BACKUP_DIR is outside BACKUP_MOUNT" ;; esac
|
|
case "$backup_real" in "$mount_real"|"$mount_real"/*) ;; *) die "BACKUP_DIR resolves outside BACKUP_MOUNT" ;; esac
|
|
"$MOUNTPOINT_BIN" -q -- "$BACKUP_MOUNT" || die "BACKUP_MOUNT is not mounted"
|
|
mount_device=$("$FINDMNT_BIN" -n -r -o MAJ:MIN --target "$BACKUP_MOUNT") \
|
|
|| die "cannot identify BACKUP_MOUNT backing device"
|
|
root_device=$("$FINDMNT_BIN" -n -r -o MAJ:MIN --target /) \
|
|
|| die "cannot identify root backing device"
|
|
[[ "$mount_device" =~ ^[0-9]+:[0-9]+$ && "$root_device" =~ ^[0-9]+:[0-9]+$ ]] \
|
|
|| die "ambiguous backing device information"
|
|
[[ "$mount_device" != "$root_device" ]] || die "BACKUP_MOUNT is backed by the root device"
|
|
|
|
mkdir -p -- "$(dirname -- "$LOCK_FILE")" "$WORK_ROOT"
|
|
exec 9>"$LOCK_FILE"
|
|
if ! flock -n 9; then
|
|
printf 'Another Gitea backup is already running\n' >&2
|
|
exit 75
|
|
fi
|
|
|
|
WORK_DIR=$(mktemp -d "$WORK_ROOT/backup.XXXXXX")
|
|
GITEA_WAS_ACTIVE=0
|
|
SERVICE_RESTORED=0
|
|
STAGED_ARCHIVE=""
|
|
STAGED_CHECKSUM=""
|
|
STAGED_METADATA=""
|
|
|
|
restore_service() {
|
|
if ((GITEA_WAS_ACTIVE)) && ! "$SYSTEMCTL_BIN" is-active --quiet gitea; then
|
|
"$SYSTEMCTL_BIN" start gitea
|
|
fi
|
|
SERVICE_RESTORED=1
|
|
}
|
|
|
|
cleanup() {
|
|
local status=$?
|
|
if ((GITEA_WAS_ACTIVE)) && ((SERVICE_RESTORED == 0)); then
|
|
restore_service || status=$?
|
|
fi
|
|
rm -rf -- "$WORK_DIR"
|
|
|
|
# The staged checksum is the rollback marker. It remains linked until all
|
|
# three no-clobber links have succeeded, so signal handling never depends on
|
|
# a shell assignment racing an atomic ln. Only unlink a final while its inode
|
|
# still proves that it is this run's hard link; a raced replacement survives.
|
|
if [[ -n "$STAGED_CHECKSUM" && -e "$STAGED_CHECKSUM" ]]; then
|
|
if [[ -n "$STAGED_METADATA" && -e "$BACKUP_DIR/$ARCHIVE.metadata" && ! -L "$BACKUP_DIR/$ARCHIVE.metadata" && "$BACKUP_DIR/$ARCHIVE.metadata" -ef "$STAGED_METADATA" ]]; then
|
|
rm -f -- "$BACKUP_DIR/$ARCHIVE.metadata"
|
|
fi
|
|
if [[ -n "$STAGED_ARCHIVE" && -e "$BACKUP_DIR/$ARCHIVE" && ! -L "$BACKUP_DIR/$ARCHIVE" && "$BACKUP_DIR/$ARCHIVE" -ef "$STAGED_ARCHIVE" ]]; then
|
|
rm -f -- "$BACKUP_DIR/$ARCHIVE"
|
|
fi
|
|
if [[ -e "$BACKUP_DIR/$ARCHIVE.sha256" && ! -L "$BACKUP_DIR/$ARCHIVE.sha256" && "$BACKUP_DIR/$ARCHIVE.sha256" -ef "$STAGED_CHECKSUM" ]]; then
|
|
rm -f -- "$BACKUP_DIR/$ARCHIVE.sha256"
|
|
fi
|
|
fi
|
|
[[ -z "$STAGED_ARCHIVE" ]] || rm -f -- "$STAGED_ARCHIVE"
|
|
[[ -z "$STAGED_CHECKSUM" ]] || rm -f -- "$STAGED_CHECKSUM"
|
|
[[ -z "$STAGED_METADATA" ]] || rm -f -- "$STAGED_METADATA"
|
|
exit "$status"
|
|
}
|
|
trap cleanup EXIT
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
|
|
install -d -m 0750 "$BACKUP_DIR"
|
|
if [[ -n "$BACKUP_OWNER" ]]; then
|
|
chown "$BACKUP_OWNER" "$BACKUP_DIR"
|
|
fi
|
|
if [[ -n "$GITEA_RUN_USER" ]]; then
|
|
chown -R "$GITEA_RUN_USER:$GITEA_RUN_USER" "$WORK_DIR"
|
|
fi
|
|
|
|
if "$SYSTEMCTL_BIN" is-active --quiet gitea; then
|
|
GITEA_WAS_ACTIVE=1
|
|
"$SYSTEMCTL_BIN" stop gitea
|
|
fi
|
|
|
|
dump_command=(
|
|
"$GITEA_BIN" dump
|
|
--config "$GITEA_CONFIG"
|
|
--work-path "$GITEA_WORK_PATH"
|
|
--tempdir "$WORK_DIR"
|
|
--type tar.gz
|
|
--file "$WORK_DIR/$ARCHIVE"
|
|
--skip-log
|
|
)
|
|
if [[ -n "$GITEA_RUN_USER" ]]; then
|
|
sudo -u "$GITEA_RUN_USER" "${dump_command[@]}"
|
|
else
|
|
"${dump_command[@]}"
|
|
fi
|
|
|
|
if ((GITEA_WAS_ACTIVE)); then
|
|
restore_service
|
|
for _ in $(seq 1 30); do
|
|
"$CURL_BIN" -fsS http://127.0.0.1:3000/api/v1/version >/dev/null && break
|
|
sleep 1
|
|
done
|
|
"$CURL_BIN" -fsS http://127.0.0.1:3000/api/v1/version >/dev/null
|
|
fi
|
|
|
|
tar -tzf "$WORK_DIR/$ARCHIVE" >"$WORK_DIR/manifest.txt"
|
|
for required in app.ini data repos gitea-db.sql; do
|
|
if ! grep -Eq "^(\\./)?${required}(/|$)" "$WORK_DIR/manifest.txt"; then
|
|
printf 'Backup archive is missing mandatory member: %s\n' "$required" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# Build every sidecar before publishing. The checksum is published last and is the
|
|
# commit marker: consumers must ignore archives without a matching sidecar.
|
|
STAGED_ARCHIVE=$(mktemp "$BACKUP_DIR/.${ARCHIVE}.archive.XXXXXX")
|
|
STAGED_CHECKSUM=$(mktemp "$BACKUP_DIR/.${ARCHIVE}.sha256.XXXXXX")
|
|
STAGED_METADATA=$(mktemp "$BACKUP_DIR/.${ARCHIVE}.metadata.XXXXXX")
|
|
install -m 0640 "$WORK_DIR/$ARCHIVE" "$STAGED_ARCHIVE"
|
|
digest=$("$SHA256SUM_BIN" "$STAGED_ARCHIVE" | awk '{print $1}')
|
|
[[ "$digest" =~ ^[[:xdigit:]]{64}$ ]] || { printf 'Invalid SHA-256 output\n' >&2; exit 1; }
|
|
printf '%s %s\n' "$digest" "$ARCHIVE" >"$STAGED_CHECKSUM"
|
|
printf 'created_utc=%s\narchive=%s\ngitea_config=%s\nformat=gitea-dump-tar.gz\n' \
|
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ARCHIVE" "$GITEA_CONFIG" >"$STAGED_METADATA"
|
|
chmod 0640 "$STAGED_ARCHIVE" "$STAGED_CHECKSUM" "$STAGED_METADATA"
|
|
if [[ -n "$BACKUP_OWNER" ]]; then
|
|
chown "$BACKUP_OWNER" "$STAGED_ARCHIVE" "$STAGED_CHECKSUM" "$STAGED_METADATA"
|
|
fi
|
|
if [[ -e "$BACKUP_DIR/$ARCHIVE" || -e "$BACKUP_DIR/$ARCHIVE.sha256" || -e "$BACKUP_DIR/$ARCHIVE.metadata" ]]; then
|
|
printf 'Refusing to overwrite an existing backup publication: %s\n' "$ARCHIVE" >&2
|
|
exit 1
|
|
fi
|
|
ln -T -- "$STAGED_METADATA" "$BACKUP_DIR/$ARCHIVE.metadata"
|
|
ln -T -- "$STAGED_ARCHIVE" "$BACKUP_DIR/$ARCHIVE"
|
|
ln -T -- "$STAGED_CHECKSUM" "$BACKUP_DIR/$ARCHIVE.sha256"
|
|
|
|
# Removing the staged checksum disarms rollback only after the checksum-last
|
|
# commit has succeeded. The other staged links are then safe to unlink.
|
|
rm -f -- "$STAGED_CHECKSUM"
|
|
rm -f -- "$STAGED_ARCHIVE" "$STAGED_METADATA"
|
|
|
|
# Expiration applies to the publication as a unit: if any final artifact has
|
|
# aged out, remove the archive, checksum, and metadata together.
|
|
while IFS= read -r -d '' expired; do
|
|
archive_path=${expired%.sha256}
|
|
archive_path=${archive_path%.metadata}
|
|
rm -f -- "$archive_path" "$archive_path.sha256" "$archive_path.metadata"
|
|
done < <(find "$BACKUP_DIR" -maxdepth 1 -type f \
|
|
\( -name 'gitea-dump-*.tar.gz' -o -name 'gitea-dump-*.tar.gz.sha256' -o -name 'gitea-dump-*.tar.gz.metadata' \) \
|
|
-mtime "+$RETENTION_DAYS" -print0)
|
|
|
|
# Preserve complete legacy archive+checksum pairs that predate metadata. New
|
|
# publications are three-file sets; any lone pair member or metadata-bearing
|
|
# incomplete set is an interrupted/malformed publication and is removed as a
|
|
# unit.
|
|
while IFS= read -r -d '' artifact; do
|
|
archive_path=${artifact%.sha256}
|
|
archive_path=${archive_path%.metadata}
|
|
if [[ -f "$archive_path.metadata" ]]; then
|
|
if [[ ! -f "$archive_path" || ! -f "$archive_path.sha256" ]]; then
|
|
rm -f -- "$archive_path" "$archive_path.sha256" "$archive_path.metadata"
|
|
fi
|
|
elif [[ ! -f "$archive_path" || ! -f "$archive_path.sha256" ]]; then
|
|
rm -f -- "$archive_path" "$archive_path.sha256" "$archive_path.metadata"
|
|
fi
|
|
done < <(find "$BACKUP_DIR" -maxdepth 1 -type f \
|
|
\( -name 'gitea-dump-*.tar.gz' -o -name 'gitea-dump-*.tar.gz.sha256' -o -name 'gitea-dump-*.tar.gz.metadata' \) -print0)
|
|
|
|
printf 'Backup verified: %s\n' "$BACKUP_DIR/$ARCHIVE"
|