Files
awoooi/scripts/backup/backup-gitea.sh
2026-07-18 22:04:04 +08:00

405 lines
18 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# =============================================================================
# WOOO AIOps - Gitea 備份腳本
# 版本: 1.1.0
# 建立日期: 2026-03-12
# 2026-05-19 ogt + Codex: 納入 repo/Ansible離機上傳改由 sync-offsite-backups.sh 統一管控。
# =============================================================================
set -euo pipefail
umask 077
source "$(dirname "$0")/common.sh"
SERVICE="gitea"
GITEA_CONTAINER="gitea"
LOCAL_REPO="${BACKUP_BASE}/gitea"
DUMP_DIR="/tmp/gitea-backup-$$"
TEXTFILE_DIR="${NODE_EXPORTER_TEXTFILE_DIR:-/home/wooo/node_exporter_textfiles}"
TEXTFILE_PATH="${GITEA_FULL_BACKUP_RECEIPT_TEXTFILE:-${TEXTFILE_DIR}/gitea_full_backup_receipt.prom}"
HOST_LABEL="${AIOPS_HOST_LABEL:-110}"
GITEA_FULL_BACKUP_COMPONENTS="database repositories app_settings lfs_objects package_registry attachments actions_logs actions_artifacts avatars hooks_custom_assets"
GITEA_RESTORE_DRILL="$(dirname "$0")/gitea-full-backup-restore-drill.sh"
GITEA_BACKUP_LOCK="${GITEA_BACKUP_LOCK:-/tmp/gitea-backup.lock}"
GITEA_STOP_TIMEOUT_SECONDS="${GITEA_STOP_TIMEOUT_SECONDS:-15}"
GITEA_DUMP_TIMEOUT_SECONDS="${GITEA_DUMP_TIMEOUT_SECONDS:-360}"
GITEA_DUMP_KILL_AFTER_SECONDS="${GITEA_DUMP_KILL_AFTER_SECONDS:-10}"
GITEA_DOCKER_CONTROL_TIMEOUT_SECONDS="${GITEA_DOCKER_CONTROL_TIMEOUT_SECONDS:-10}"
GITEA_DOCKER_CONTROL_KILL_AFTER_SECONDS="${GITEA_DOCKER_CONTROL_KILL_AFTER_SECONDS:-3}"
GITEA_START_TIMEOUT_SECONDS="${GITEA_START_TIMEOUT_SECONDS:-60}"
GITEA_MAX_OUTAGE_SECONDS="${GITEA_MAX_OUTAGE_SECONDS:-600}"
GITEA_LOCAL_HEALTH_URL="${GITEA_LOCAL_HEALTH_URL:-http://127.0.0.1:3001/api/healthz}"
TRANSIENT_CONTAINER=""
PRIMARY_STOPPED=0
PRIMARY_RESTART_VERIFIED=0
OUTAGE_STARTED_AT=0
OUTAGE_SECONDS=0
label_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
write_gitea_full_backup_receipt() {
local status="$1"
local timestamp="$2"
local duration="$3"
local snapshot_id="${4:-unknown}"
local consistent_offline_window="${5:-0}"
local service_restart_verified="${6:-0}"
local outage_seconds="${7:-0}"
local structural_drill_performed="${8:-0}"
local tmp
local host
local service_label
local status_label
local component
local ok=0
local failed=1
if [ "${status}" = "success" ]; then
ok=1
failed=0
fi
host="$(label_escape "${HOST_LABEL}")"
service_label="$(label_escape "${SERVICE}")"
status_label="$(label_escape "${status}")"
install -d -m 755 "${TEXTFILE_DIR}" || return 0
tmp="$(mktemp "${TEXTFILE_PATH}.tmp.XXXXXX")" || return 0
{
echo "# HELP awoooi_gitea_full_backup_last_success_timestamp Unix timestamp of the latest successful Gitea full-server backup receipt."
echo "# TYPE awoooi_gitea_full_backup_last_success_timestamp gauge"
echo "# HELP awoooi_gitea_full_backup_last_run_failed Whether the latest Gitea full-server backup run failed."
echo "# TYPE awoooi_gitea_full_backup_last_run_failed gauge"
echo "# HELP awoooi_gitea_full_backup_duration_seconds Duration of the latest Gitea full-server backup run."
echo "# TYPE awoooi_gitea_full_backup_duration_seconds gauge"
echo "# HELP awoooi_gitea_full_backup_dump_file_present Whether the Gitea dump file was produced before restic backup."
echo "# TYPE awoooi_gitea_full_backup_dump_file_present gauge"
echo "# HELP awoooi_gitea_full_backup_restic_snapshot_present Whether a restic snapshot id was recorded for the Gitea dump."
echo "# TYPE awoooi_gitea_full_backup_restic_snapshot_present gauge"
echo "# HELP awoooi_gitea_full_backup_component_receipt Whether the Gitea full-server dump includes a redacted component receipt."
echo "# TYPE awoooi_gitea_full_backup_component_receipt gauge"
echo "# HELP awoooi_gitea_full_backup_restore_drill_performed Whether a non-production Gitea full-backup restore drill receipt is present."
echo "# TYPE awoooi_gitea_full_backup_restore_drill_performed gauge"
echo "# HELP awoooi_gitea_full_backup_secret_value_collected Whether this receipt collected secret values."
echo "# TYPE awoooi_gitea_full_backup_secret_value_collected gauge"
echo "# HELP awoooi_gitea_full_backup_production_restore_performed Whether this receipt restored anything into production."
echo "# TYPE awoooi_gitea_full_backup_production_restore_performed gauge"
echo "# HELP awoooi_gitea_full_backup_consistent_offline_window Whether the dump was created while the primary Gitea service was stopped."
echo "# TYPE awoooi_gitea_full_backup_consistent_offline_window gauge"
echo "# HELP awoooi_gitea_full_backup_service_restart_verified Whether Gitea was independently reachable after the bounded offline window."
echo "# TYPE awoooi_gitea_full_backup_service_restart_verified gauge"
echo "# HELP awoooi_gitea_full_backup_service_outage_seconds Duration of the bounded Gitea offline backup window."
echo "# TYPE awoooi_gitea_full_backup_service_outage_seconds gauge"
echo "awoooi_gitea_full_backup_last_success_timestamp{host=\"${host}\",service=\"${service_label}\"} $([ "${ok}" = "1" ] && echo "${timestamp}" || echo 0)"
echo "awoooi_gitea_full_backup_last_run_failed{host=\"${host}\",service=\"${service_label}\",status=\"${status_label}\"} ${failed}"
echo "awoooi_gitea_full_backup_duration_seconds{host=\"${host}\",service=\"${service_label}\"} ${duration}"
echo "awoooi_gitea_full_backup_dump_file_present{host=\"${host}\",service=\"${service_label}\"} ${ok}"
echo "awoooi_gitea_full_backup_restic_snapshot_present{host=\"${host}\",service=\"${service_label}\",snapshot_id=\"$(label_escape "${snapshot_id}")\"} ${ok}"
for component in ${GITEA_FULL_BACKUP_COMPONENTS}; do
echo "awoooi_gitea_full_backup_component_receipt{host=\"${host}\",service=\"${service_label}\",component=\"$(label_escape "${component}")\",receipt=\"gitea_dump_restic\"} ${ok}"
done
echo "awoooi_gitea_full_backup_restore_drill_performed{host=\"${host}\",service=\"${service_label}\"} ${structural_drill_performed}"
echo "awoooi_gitea_full_backup_secret_value_collected{host=\"${host}\",service=\"${service_label}\"} 0"
echo "awoooi_gitea_full_backup_production_restore_performed{host=\"${host}\",service=\"${service_label}\"} 0"
echo "awoooi_gitea_full_backup_consistent_offline_window{host=\"${host}\",service=\"${service_label}\"} ${consistent_offline_window}"
echo "awoooi_gitea_full_backup_service_restart_verified{host=\"${host}\",service=\"${service_label}\"} ${service_restart_verified}"
echo "awoooi_gitea_full_backup_service_outage_seconds{host=\"${host}\",service=\"${service_label}\"} ${outage_seconds}"
} >"${tmp}"
mv "${tmp}" "${TEXTFILE_PATH}" || return 0
chmod 0644 "${TEXTFILE_PATH}" || true
}
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if [ -n "${TRANSIENT_CONTAINER}" ]; then
if ! remove_transient_container; then
status=1
fi
fi
if [ "${PRIMARY_STOPPED}" -eq 1 ]; then
if ! start_and_verify_primary; then
status=1
fi
fi
rm -rf "${DUMP_DIR}"
exit "${status}"
}
validate_positive_integer() {
case "$2" in
''|*[!0-9]*|0) log_error "$1 must be a positive integer"; return 64 ;;
esac
}
require_commands() {
local command_name configured_outage_bound
for command_name in curl docker flock python3 restic timeout; do
if ! command -v "${command_name}" >/dev/null 2>&1; then
log_error "Required command missing: ${command_name}"
return 69
fi
done
[ -x "${GITEA_RESTORE_DRILL}" ] || {
log_error "Gitea structural restore verifier is unavailable"
return 69
}
[ -r "${RESTIC_PASSWORD_FILE}" ] || {
log_error "Restic password reference is unavailable"
return 69
}
validate_positive_integer GITEA_STOP_TIMEOUT_SECONDS "${GITEA_STOP_TIMEOUT_SECONDS}"
validate_positive_integer GITEA_DUMP_TIMEOUT_SECONDS "${GITEA_DUMP_TIMEOUT_SECONDS}"
validate_positive_integer GITEA_DUMP_KILL_AFTER_SECONDS "${GITEA_DUMP_KILL_AFTER_SECONDS}"
validate_positive_integer GITEA_DOCKER_CONTROL_TIMEOUT_SECONDS "${GITEA_DOCKER_CONTROL_TIMEOUT_SECONDS}"
validate_positive_integer GITEA_DOCKER_CONTROL_KILL_AFTER_SECONDS "${GITEA_DOCKER_CONTROL_KILL_AFTER_SECONDS}"
validate_positive_integer GITEA_START_TIMEOUT_SECONDS "${GITEA_START_TIMEOUT_SECONDS}"
validate_positive_integer GITEA_MAX_OUTAGE_SECONDS "${GITEA_MAX_OUTAGE_SECONDS}"
configured_outage_bound=$((
GITEA_STOP_TIMEOUT_SECONDS
+ GITEA_DUMP_TIMEOUT_SECONDS
+ GITEA_DUMP_KILL_AFTER_SECONDS
+ GITEA_START_TIMEOUT_SECONDS
+ (4 * GITEA_DOCKER_CONTROL_TIMEOUT_SECONDS)
+ (4 * GITEA_DOCKER_CONTROL_KILL_AFTER_SECONDS)
))
if [ "${configured_outage_bound}" -gt "${GITEA_MAX_OUTAGE_SECONDS}" ]; then
log_error "Configured Gitea backup outage bound exceeds ${GITEA_MAX_OUTAGE_SECONDS}s"
return 64
fi
}
bounded_docker_control() {
timeout --signal=TERM \
--kill-after="${GITEA_DOCKER_CONTROL_KILL_AFTER_SECONDS}s" \
"${GITEA_DOCKER_CONTROL_TIMEOUT_SECONDS}s" \
docker "$@"
}
remove_transient_container() {
local transient="${TRANSIENT_CONTAINER}"
local remove_status=0
[ -n "${transient}" ] || return 0
if ! bounded_docker_control rm -f "${transient}" >/dev/null 2>&1; then
log_error "Gitea one-shot backup container removal timed out or failed"
remove_status=1
fi
TRANSIENT_CONTAINER=""
return "${remove_status}"
}
wait_for_primary_health() {
local deadline=$((SECONDS + GITEA_START_TIMEOUT_SECONDS))
local health_body
while [ "${SECONDS}" -lt "${deadline}" ]; do
if [ "$(bounded_docker_control inspect -f '{{.State.Running}}' "${GITEA_CONTAINER}" 2>/dev/null || true)" = "true" ]; then
health_body="$(curl -fsS --max-time 5 "${GITEA_LOCAL_HEALTH_URL}" 2>/dev/null || true)"
if printf '%s' "${health_body}" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"pass"'; then
return 0
fi
fi
sleep 2
done
return 1
}
start_and_verify_primary() {
local running
running="$(bounded_docker_control inspect -f '{{.State.Running}}' "${GITEA_CONTAINER}" 2>/dev/null || true)"
if [ "${running}" != "true" ]; then
if ! bounded_docker_control start "${GITEA_CONTAINER}" >/dev/null; then
log_error "Gitea failed to restart after the backup window"
return 1
fi
fi
if ! wait_for_primary_health; then
log_error "Gitea post-backup health verification timed out"
return 1
fi
PRIMARY_STOPPED=0
PRIMARY_RESTART_VERIFIED=1
if [ "${OUTAGE_STARTED_AT}" -gt 0 ]; then
OUTAGE_SECONDS=$(($(date +%s) - OUTAGE_STARTED_AT))
fi
if [ "${OUTAGE_SECONDS}" -gt "${GITEA_MAX_OUTAGE_SECONDS}" ]; then
log_error "Gitea backup outage exceeded ${GITEA_MAX_OUTAGE_SECONDS}s"
return 1
fi
log_success "Gitea primary restarted and independently reachable (outage=${OUTAGE_SECONDS}s)"
}
offline_gitea_dump() {
local image_id network_rows network_name network_count git_uid host_uid timestamp dump_status stop_status transient_cleanup_status
[ "$(docker inspect -f '{{.State.Running}}' "${GITEA_CONTAINER}" 2>/dev/null || true)" = "true" ] || {
log_error "Gitea primary is not running before backup"
return 1
}
wait_for_primary_health || {
log_error "Gitea primary is not reachable before backup"
return 1
}
image_id="$(docker inspect -f '{{.Image}}' "${GITEA_CONTAINER}")"
[[ "${image_id}" =~ ^sha256:[0-9a-f]{64}$ ]] || {
log_error "Gitea image identity is invalid"
return 1
}
network_rows="$(docker inspect -f '{{range $name, $value := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' "${GITEA_CONTAINER}" | sed '/^$/d')"
network_count="$(printf '%s\n' "${network_rows}" | sed '/^$/d' | wc -l | tr -d ' ')"
[ "${network_count}" -eq 1 ] || {
log_error "Gitea backup requires exactly one fixed Docker network"
return 1
}
network_name="$(printf '%s\n' "${network_rows}" | head -n 1)"
[[ "${network_name}" =~ ^[A-Za-z0-9_.-]+$ ]] || {
log_error "Gitea Docker network identity is invalid"
return 1
}
git_uid="$(docker exec -u git "${GITEA_CONTAINER}" id -u)"
host_uid="$(id -u)"
[ "${git_uid}" = "${host_uid}" ] || {
log_error "Gitea dump staging UID does not match the controller UID"
return 1
}
timestamp="$(date +%Y%m%d%H%M%S)"
TRANSIENT_CONTAINER="agent99-gitea-backup-${timestamp}-$$"
OUTAGE_STARTED_AT="$(date +%s)"
PRIMARY_STOPPED=1
set +e
timeout --signal=TERM \
--kill-after="${GITEA_DOCKER_CONTROL_KILL_AFTER_SECONDS}s" \
"$((GITEA_STOP_TIMEOUT_SECONDS + GITEA_DOCKER_CONTROL_TIMEOUT_SECONDS))s" \
docker stop --time "${GITEA_STOP_TIMEOUT_SECONDS}" "${GITEA_CONTAINER}" >/dev/null
stop_status=$?
set -e
if [ "${stop_status}" -ne 0 ]; then
log_error "Gitea primary could not enter the bounded offline backup window"
start_and_verify_primary || true
return 1
fi
set +e
timeout --signal=TERM \
--kill-after="${GITEA_DUMP_KILL_AFTER_SECONDS}s" \
"${GITEA_DUMP_TIMEOUT_SECONDS}s" docker run --pull=never \
--name "${TRANSIENT_CONTAINER}" \
--network "${network_name}" \
--volumes-from "${GITEA_CONTAINER}" \
--mount "type=bind,src=${DUMP_DIR},dst=/backup-out" \
--user "${git_uid}" \
--workdir /backup-out \
--cpus "${BACKUP_DOCKER_CPUS}" \
--memory "${BACKUP_DOCKER_MEMORY}" \
--memory-swap "${BACKUP_DOCKER_MEMORY_SWAP}" \
--security-opt no-new-privileges:true \
--entrypoint /usr/local/bin/gitea \
"${image_id}" dump \
-c /data/gitea/conf/app.ini \
-f /backup-out/gitea-dump.zip \
-t /tmp
dump_status=$?
set -e
transient_cleanup_status=0
remove_transient_container || transient_cleanup_status=$?
if ! start_and_verify_primary; then
return 1
fi
if [ "${transient_cleanup_status}" -ne 0 ]; then
return "${transient_cleanup_status}"
fi
if [ "${dump_status}" -ne 0 ]; then
log_error "Offline Gitea dump failed (exit=${dump_status})"
return "${dump_status}"
fi
[ -s "${DUMP_DIR}/gitea-dump.zip" ] || {
log_error "Offline Gitea dump did not produce a non-empty archive"
return 1
}
log_success "Gitea consistent offline dump completed"
}
main() {
local start_time
local tags
local snapshot_id
local duration
local structural_drill_performed=0
start_time=$(date +%s)
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
log_info "========== 開始 Gitea 備份 =========="
require_commands
exec 9>"${GITEA_BACKUP_LOCK}"
if ! flock -n 9; then
log_error "Gitea backup is already running"
return 75
fi
install -d -m 700 "${DUMP_DIR}"
log_info "執行一致性 Gitea offline dump..."
if ! offline_gitea_dump; then
duration=$(($(date +%s) - start_time))
write_gitea_full_backup_receipt "offline_dump_failed" "0" "${duration}" "none" 0 "${PRIMARY_RESTART_VERIFIED}" "${OUTAGE_SECONDS}" 0 || true
notify_clawbot "failed" "${SERVICE}" "Gitea 一致性備份失敗;服務重啟驗證=${PRIMARY_RESTART_VERIFIED}" "${duration}"
return 1
fi
if ! "${GITEA_RESTORE_DRILL}" --dump-zip "${DUMP_DIR}/gitea-dump.zip"; then
duration=$(($(date +%s) - start_time))
write_gitea_full_backup_receipt "structural_drill_failed" "0" "${duration}" "none" 1 "${PRIMARY_RESTART_VERIFIED}" "${OUTAGE_SECONDS}" 0 || true
log_error "Gitea dump structural verifier failed"
notify_clawbot "failed" "${SERVICE}" "Gitea 備份結構驗證失敗" "${duration}"
return 1
fi
structural_drill_performed=1
if [ ! -d "${LOCAL_REPO}/data" ]; then
log_info "初始化本地 Restic 倉庫..."
if ! restic -r "${LOCAL_REPO}" init --password-file "${RESTIC_PASSWORD_FILE}"; then
duration=$(($(date +%s) - start_time))
write_gitea_full_backup_receipt "restic_init_failed" "0" "${duration}" "none" 1 "${PRIMARY_RESTART_VERIFIED}" "${OUTAGE_SECONDS}" "${structural_drill_performed}" || true
log_error "Restic 初始化失敗"
notify_clawbot "failed" "${SERVICE}" "Gitea Restic 初始化失敗"
exit 1
fi
fi
tags=$(build_tags "${SERVICE}")
if ! restic -r "${LOCAL_REPO}" backup "${DUMP_DIR}" \
--password-file "${RESTIC_PASSWORD_FILE}" \
${tags}; then
duration=$(($(date +%s) - start_time))
write_gitea_full_backup_receipt "restic_backup_failed" "0" "${duration}" "none" 1 "${PRIMARY_RESTART_VERIFIED}" "${OUTAGE_SECONDS}" "${structural_drill_performed}" || true
log_error "Restic 備份失敗"
notify_clawbot "failed" "${SERVICE}" "Gitea Restic 備份失敗"
exit 1
fi
snapshot_id=$(restic -r "${LOCAL_REPO}" snapshots --latest 1 --json --password-file "${RESTIC_PASSWORD_FILE}" 2>/dev/null | grep -oP '"short_id":"\K[^"]+' | head -1 || true)
log_success "Restic 備份完成: ${snapshot_id:-unknown}"
log_info "執行 GFS 清理..."
cleanup_old_backups "${LOCAL_REPO}"
log_info "Offsite copy is handled by sync-offsite-backups.sh; no direct rclone sync here."
duration=$(($(date +%s) - start_time))
write_gitea_full_backup_receipt "success" "$(date +%s)" "${duration}" "${snapshot_id:-unknown}" 1 "${PRIMARY_RESTART_VERIFIED}" "${OUTAGE_SECONDS}" "${structural_drill_performed}" || true
log_success "========== Gitea 備份完成 (${duration}s) =========="
notify_clawbot "success" "${SERVICE}" "Gitea 備份完成" "${duration}"
}
main "$@"