401 lines
11 KiB
Bash
401 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
# Gitea application-consistent backup script.
|
|
#
|
|
# Operator-attestation basis:
|
|
# - No intentionally required nested mount boundaries exist beneath the approved
|
|
# source roots.
|
|
# - The SQLite database is captured with SQLite's online .backup mechanism.
|
|
# - Filesystem-tree copies are best effort while Gitea remains online.
|
|
# - This script does not perform runtime mount-boundary or nested-symlink scans.
|
|
#
|
|
# The script archives only a staged SQLite backup, not the live SQLite DB,
|
|
# WAL, SHM, or journal files.
|
|
|
|
set -euo pipefail
|
|
IFS=$'\n\t'
|
|
umask 077
|
|
|
|
readonly APP_INI_PATH="/opt/gitea/data/custom/conf/app.ini"
|
|
readonly SQLITE_DB_PATH="/opt/gitea/data/data/gitea.db"
|
|
readonly REPO_PATH="/opt/gitea/data/repositories"
|
|
readonly LFS_PATH="/opt/gitea/data/lfs"
|
|
readonly ATTACHMENTS_PATH="/opt/gitea/data/data/attachments"
|
|
readonly AVATARS_PATH="/opt/gitea/data/data/avatars"
|
|
readonly PACKAGES_PATH="/opt/gitea/data/data/packages"
|
|
readonly REPO_ARCHIVE_PATH="/opt/gitea/data/data/repo-archive"
|
|
readonly REPO_AVATARS_PATH="/opt/gitea/data/data/repo-avatars"
|
|
|
|
readonly GCS_BUCKET="gs://vauco-gitea-backups-20260901"
|
|
readonly GCS_PREFIX="manual"
|
|
readonly STAGING_DIR="/var/backups/gitea"
|
|
readonly LOCK_FILE="/var/run/gitea-backup.lock"
|
|
readonly LOG_FILE="/var/log/gitea-backup.log"
|
|
readonly REQUIRED_FREE_KB=646368
|
|
|
|
workspace=""
|
|
workspace_preserved=0
|
|
log_ready=0
|
|
|
|
log_status() {
|
|
local phase="$1"
|
|
local status="$2"
|
|
local category="$3"
|
|
|
|
if [ "${log_ready}" -eq 1 ]; then
|
|
printf '%s phase=%s status=%s category=%s\n' \
|
|
"$(date -u --iso-8601=seconds)" \
|
|
"${phase}" \
|
|
"${status}" \
|
|
"${category}" >> "${LOG_FILE}"
|
|
else
|
|
printf '%s\n' \
|
|
"gitea-backup phase=${phase} status=${status} category=${category}" >&2
|
|
fi
|
|
}
|
|
|
|
fail_without_workspace() {
|
|
log_status "$1" "FAILURE" "$2"
|
|
exit 1
|
|
}
|
|
|
|
workspace_is_safe() {
|
|
[ -n "${workspace}" ] &&
|
|
[ -d "${workspace}" ] &&
|
|
[ ! -L "${workspace}" ] &&
|
|
[ "$(dirname -- "${workspace}")" = "${STAGING_DIR}" ] &&
|
|
[ "$(stat -c '%u:%g:%a' -- "${workspace}")" = "0:0:700" ]
|
|
}
|
|
|
|
remove_workspace() {
|
|
workspace_is_safe || return 1
|
|
rm -rf -- "${workspace}"
|
|
}
|
|
|
|
preserve_workspace() {
|
|
workspace_is_safe || return 1
|
|
|
|
if find -- "${workspace}" -xdev -type l -print -quit 2>/dev/null |
|
|
grep -q . >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
if find -- "${workspace}" -xdev \
|
|
! -type d ! -type f ! -type l \
|
|
-print -quit 2>/dev/null |
|
|
grep -q . >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
if ! find -- "${workspace}" -xdev -type d \
|
|
-exec chmod 700 -- {} + >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
if ! find -- "${workspace}" -xdev -type f \
|
|
-exec chmod 600 -- {} + >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
workspace_preserved=1
|
|
trap - EXIT
|
|
return 0
|
|
}
|
|
|
|
fail_after_workspace() {
|
|
log_status "$1" "FAILURE" "$2"
|
|
|
|
if ! preserve_workspace; then
|
|
log_status "PRESERVATION" "FAILURE" "WorkspacePreservationFailed"
|
|
fi
|
|
|
|
exit 1
|
|
}
|
|
|
|
cleanup_on_exit() {
|
|
local rc=$?
|
|
|
|
if [ "${workspace_preserved}" -eq 0 ]; then
|
|
remove_workspace >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
exit "${rc}"
|
|
}
|
|
|
|
require_command() {
|
|
if ! command -v "$1" >/dev/null 2>&1; then
|
|
fail_without_workspace "PREFLIGHT" "RequiredCommandMissing"
|
|
fi
|
|
}
|
|
|
|
validate_regular() {
|
|
if ! [ -f "$1" ] || [ -L "$1" ]; then
|
|
fail_without_workspace "PREFLIGHT" "$2"
|
|
fi
|
|
}
|
|
|
|
validate_directory() {
|
|
if ! [ -d "$1" ] || [ -L "$1" ]; then
|
|
fail_without_workspace "PREFLIGHT" "$2"
|
|
fi
|
|
}
|
|
|
|
validate_archive_manifest() {
|
|
local archive_path="$1"
|
|
|
|
tar -tzf "${archive_path}" >/dev/null 2>&1 || return 1
|
|
|
|
tar -tf "${archive_path}" 2>/dev/null |
|
|
awk '
|
|
BEGIN {
|
|
allowed["database"] = 1
|
|
allowed["config"] = 1
|
|
allowed["repositories"] = 1
|
|
allowed["lfs"] = 1
|
|
allowed["data"] = 1
|
|
|
|
required["database/"] = 0
|
|
required["config/"] = 0
|
|
required["repositories/"] = 0
|
|
required["lfs/"] = 0
|
|
required["data/"] = 0
|
|
}
|
|
|
|
/^\// { bad = 1; exit 1 }
|
|
/(^|\/)\.\.(\/|$)/ { bad = 1; exit 1 }
|
|
|
|
{
|
|
member = $0
|
|
split(member, parts, "/")
|
|
root = parts[1]
|
|
|
|
if (!(root in allowed)) {
|
|
bad = 1
|
|
exit 1
|
|
}
|
|
|
|
if (member in required) {
|
|
required[member] = 1
|
|
}
|
|
}
|
|
|
|
END {
|
|
if (bad) {
|
|
exit 1
|
|
}
|
|
|
|
for (entry in required) {
|
|
if (required[entry] != 1) {
|
|
exit 1
|
|
}
|
|
}
|
|
}'
|
|
}
|
|
|
|
copy_source() {
|
|
if ! cp -a -- "$1" "$2"; then
|
|
fail_after_workspace "STAGING" "$3"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
local required_command=""
|
|
local archive_root=""
|
|
local run_id=""
|
|
local archive_name=""
|
|
local archive_path=""
|
|
local checksum_path=""
|
|
local archive_object=""
|
|
local checksum_object=""
|
|
|
|
for required_command in \
|
|
flock df awk mktemp mkdir sqlite3 cp tar sha256sum gcloud \
|
|
stat find chmod rm dirname grep date printf; do
|
|
require_command "${required_command}"
|
|
done
|
|
|
|
if ! [ -f "${LOG_FILE}" ] || [ -L "${LOG_FILE}" ]; then
|
|
printf '%s\n' \
|
|
"gitea-backup phase=PREFLIGHT status=FAILURE category=LogFileInvalid" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$(stat -c '%u:%g:%a' -- "${LOG_FILE}")" != "0:0:600" ]; then
|
|
printf '%s\n' \
|
|
"gitea-backup phase=PREFLIGHT status=FAILURE category=LogFilePermissionsInvalid" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! [ -w "${LOG_FILE}" ]; then
|
|
printf '%s\n' \
|
|
"gitea-backup phase=PREFLIGHT status=FAILURE category=LogFileUnavailable" >&2
|
|
exit 1
|
|
fi
|
|
|
|
log_ready=1
|
|
log_status "PREFLIGHT" "START" "BackupJob"
|
|
|
|
if ! [ -d "${STAGING_DIR}" ] || [ -L "${STAGING_DIR}" ]; then
|
|
fail_without_workspace "PREFLIGHT" "StagingDirectoryInvalid"
|
|
fi
|
|
|
|
if [ "$(stat -c '%u:%g:%a' -- "${STAGING_DIR}")" != "0:0:700" ]; then
|
|
fail_without_workspace "PREFLIGHT" "StagingDirectoryPermissionsInvalid"
|
|
fi
|
|
|
|
if ! [ -w "${STAGING_DIR}" ]; then
|
|
fail_without_workspace "PREFLIGHT" "StagingDirectoryUnavailable"
|
|
fi
|
|
|
|
if ! df --output=avail -- "${STAGING_DIR}" 2>/dev/null |
|
|
awk -v required_kb="${REQUIRED_FREE_KB}" '
|
|
NR == 2 {
|
|
checked = 1
|
|
exit !($1 > required_kb)
|
|
}
|
|
END {
|
|
if (!checked) {
|
|
exit 1
|
|
}
|
|
}'; then
|
|
fail_without_workspace "PREFLIGHT" "InsufficientStagingCapacity"
|
|
fi
|
|
|
|
validate_regular "${APP_INI_PATH}" "AppIniInvalid"
|
|
validate_regular "${SQLITE_DB_PATH}" "SQLiteDatabaseInvalid"
|
|
|
|
validate_directory "${REPO_PATH}" "RepositoriesInvalid"
|
|
validate_directory "${LFS_PATH}" "LfsInvalid"
|
|
validate_directory "${ATTACHMENTS_PATH}" "AttachmentsInvalid"
|
|
validate_directory "${AVATARS_PATH}" "AvatarsInvalid"
|
|
validate_directory "${PACKAGES_PATH}" "PackagesInvalid"
|
|
validate_directory "${REPO_ARCHIVE_PATH}" "RepositoryArchivesInvalid"
|
|
validate_directory "${REPO_AVATARS_PATH}" "RepositoryAvatarsInvalid"
|
|
|
|
if ! workspace="$(mktemp -d -p "${STAGING_DIR}" "backup.XXXXXX")"; then
|
|
fail_without_workspace "PREFLIGHT" "WorkspaceCreationFailed"
|
|
fi
|
|
|
|
if ! workspace_is_safe; then
|
|
fail_after_workspace "PREFLIGHT" "WorkspaceValidationFailed"
|
|
fi
|
|
|
|
trap cleanup_on_exit EXIT
|
|
|
|
archive_root="${workspace}/archive_root"
|
|
|
|
if ! mkdir -p -- \
|
|
"${archive_root}/database" \
|
|
"${archive_root}/config" \
|
|
"${archive_root}/data"; then
|
|
fail_after_workspace "STAGING" "ArchiveRootCreationFailed"
|
|
fi
|
|
|
|
log_status "DB_COPY" "START" "SQLiteOnlineBackup"
|
|
if ! sqlite3 "${SQLITE_DB_PATH}" \
|
|
".backup '${archive_root}/database/gitea.db'"; then
|
|
fail_after_workspace "DB_COPY" "SQLiteBackupFailed"
|
|
fi
|
|
log_status "DB_COPY" "SUCCESS" "SQLiteOnlineBackupComplete"
|
|
|
|
log_status "STAGING" "START" "CopyingData"
|
|
|
|
copy_source "${APP_INI_PATH}" \
|
|
"${archive_root}/config/app.ini" \
|
|
"CopyAppIniFailed"
|
|
|
|
copy_source "${REPO_PATH}" \
|
|
"${archive_root}/repositories" \
|
|
"CopyRepositoriesFailed"
|
|
|
|
copy_source "${LFS_PATH}" \
|
|
"${archive_root}/lfs" \
|
|
"CopyLfsFailed"
|
|
|
|
copy_source "${ATTACHMENTS_PATH}" \
|
|
"${archive_root}/data/attachments" \
|
|
"CopyAttachmentsFailed"
|
|
|
|
copy_source "${AVATARS_PATH}" \
|
|
"${archive_root}/data/avatars" \
|
|
"CopyAvatarsFailed"
|
|
|
|
copy_source "${PACKAGES_PATH}" \
|
|
"${archive_root}/data/packages" \
|
|
"CopyPackagesFailed"
|
|
|
|
copy_source "${REPO_ARCHIVE_PATH}" \
|
|
"${archive_root}/data/repo-archive" \
|
|
"CopyRepositoryArchivesFailed"
|
|
|
|
copy_source "${REPO_AVATARS_PATH}" \
|
|
"${archive_root}/data/repo-avatars" \
|
|
"CopyRepositoryAvatarsFailed"
|
|
|
|
log_status "STAGING" "SUCCESS" "CopyComplete"
|
|
|
|
run_id="${workspace##*/}"
|
|
archive_name="gitea-backup-${run_id}.tar.gz"
|
|
archive_path="${workspace}/${archive_name}"
|
|
checksum_path="${archive_path}.sha256"
|
|
archive_object="${GCS_BUCKET}/${GCS_PREFIX}/${archive_name}"
|
|
checksum_object="${GCS_BUCKET}/${GCS_PREFIX}/${archive_name}.sha256"
|
|
|
|
log_status "ARCHIVE" "START" "TarCreation"
|
|
if ! tar -czf "${archive_path}" \
|
|
-C "${archive_root}" \
|
|
database config repositories lfs data; then
|
|
fail_after_workspace "ARCHIVE" "TarCreationFailed"
|
|
fi
|
|
|
|
if ! validate_archive_manifest "${archive_path}"; then
|
|
fail_after_workspace "ARCHIVE" "ManifestInvalid"
|
|
fi
|
|
log_status "ARCHIVE" "SUCCESS" "ManifestValidated"
|
|
|
|
if ! (
|
|
cd -- "${workspace}"
|
|
sha256sum -b -- "${archive_name}" > "${checksum_path}"
|
|
); then
|
|
fail_after_workspace "CHECKSUM" "ChecksumCreationFailed"
|
|
fi
|
|
log_status "CHECKSUM" "SUCCESS" "ChecksumGenerated"
|
|
|
|
log_status "UPLOAD_ARCHIVE" "START" "GCS"
|
|
if ! gcloud storage cp \
|
|
--if-generation-match=0 \
|
|
--quiet \
|
|
"${archive_path}" \
|
|
"${archive_object}" >/dev/null 2>&1; then
|
|
fail_after_workspace "UPLOAD_ARCHIVE" "ArchiveUploadFailed"
|
|
fi
|
|
log_status "UPLOAD_ARCHIVE" "SUCCESS" "ArchiveUploadComplete"
|
|
|
|
log_status "UPLOAD_CHECKSUM" "START" "GCS"
|
|
if ! gcloud storage cp \
|
|
--if-generation-match=0 \
|
|
--quiet \
|
|
"${checksum_path}" \
|
|
"${checksum_object}" >/dev/null 2>&1; then
|
|
fail_after_workspace "UPLOAD_CHECKSUM" "ChecksumUploadFailed"
|
|
fi
|
|
log_status "UPLOAD_CHECKSUM" "SUCCESS" "ChecksumUploadComplete"
|
|
|
|
if ! remove_workspace; then
|
|
fail_after_workspace "CLEANUP" "WorkspaceRemovalFailed"
|
|
fi
|
|
|
|
workspace=""
|
|
trap - EXIT
|
|
log_status "JOB" "SUCCESS" "BackupComplete"
|
|
}
|
|
|
|
(
|
|
flock -n 200 || {
|
|
printf '%s\n' \
|
|
"gitea-backup phase=PREFLIGHT status=FAILURE category=LockHeld" >&2
|
|
exit 1
|
|
}
|
|
|
|
main
|
|
) 200>"${LOCK_FILE}"
|