Skip to content

Repo Restore

Odtwarzanie serwera z repozytorium

Skrypt repo-restore.sh odpowiada za odtworzenie wybranych plików konfiguracyjnych z repozytorium na host.

Zakres działania skryptu:

  • odtworzenie definicji Dockera (Docker Compose oraz projekty) z repozytorium do katalogów roboczych
  • odtworzenie wybranych plików i jednostek z /etc, /etc/systemd oraz /etc/systemd/system
  • blokada równoległego uruchomienia (mechanizm lock)
  • walidacja pliku konfiguracyjnego i wymaganych zmiennych środowiskowych
  • zapis logów i zestawienia zmian do katalogu uruchomienia
  • retencja katalogów uruchomień

Repozytorium jest traktowane jako źródło prawdy — pliki obecne w repozytorium nadpisują pliki systemowe.

Warning

Uruchomienie bez --dry-run wymaga ręcznego potwierdzenia poprzez wpisanie RESTORE.


Wymagany skrypt wspólny

Wymagane skrypty

Aby skrypt wykonał się poprawnie, w tym samym folderze musi znajdować się repo-common.sh, który zawiera funkcje współdzielone przez wszystkie skrypty synchronizacji i odtwarzania repo.

repo-common.sh


Wymagany plik konfiguracyjny repo.env

Wymagany plik konfiguracyjny

Plik repo.env jest wymagany do działania skryptów repo-sync.sh oraz repo-restore.sh.

Instrukcja utworzenia pliku znajduje się w dokumencie instalacji Git:

Tworzenie pliku repo.env


Tworzenie katalogów

Tworzymy katalog uruchomień:

sudo mkdir -p /srv/backups/repo/runs

Tworzenie skryptu

Tworzymy plik:

sudo micro /srv/config/scripts/repo-restore.sh

W pliku umieszczamy:

repo-restore.sh
#!/usr/bin/env bash
# /srv/config/scripts/repo-restore.sh
# Restore selected config + docker definitions from repo back to the host.

set -euo pipefail
IFS=$'\n\t'
umask 027

# --- Load common library (robust path) ---
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
LIB_FILE="${SCRIPT_DIR}/repo-common.sh"

if [[ ! -f "${LIB_FILE}" ]]; then
  echo "CRITICAL: Library ${LIB_FILE} not found." >&2
  exit 1
fi

# shellcheck disable=SC1090
source "${LIB_FILE}"

# --- Defaults / paths ---
ENV_FILE="/srv/backups/repo/repo.env"
RUNS_BASE_DIR="/srv/backups/repo/runs"
RUN_RETENTION_DAYS_DEFAULT="30"

# --- CLI ---
DRY_RUN=0
TARGET=""
CLEAN_DELETE=0   # optional destructive mode (off by default)

usage() {
  cat <<'EOF'
Usage: repo-restore.sh [--dry-run|-n] [--delete] {compose|projects|docker|etc|systemd|units|host|all}

  --dry-run, -n   Show what would change, do not write anything.
  --delete        Allow rsync to delete files not present in repo (docker restore only).
EOF
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --dry-run|-n) DRY_RUN=1; shift ;;
    --delete) CLEAN_DELETE=1; shift ;;
    -h|--help) usage; exit 0 ;;
    *) TARGET="$1"; shift ;;
  esac
done

TARGET="${TARGET:-host}"

# --- Run dir (created early) ---
TS="$(date +%Y%m%d_%H%M)"
RUN_DIR="${RUNS_BASE_DIR}/${TS}"
mkdir -p "${RUNS_BASE_DIR}"
setup_run_env "${RUN_DIR}"

LOG_FILE="${RUN_DIR}/restore.log"
CHANGES_FILE="${RUN_DIR}/changes.txt"
: > "${CHANGES_FILE}"

# Log everything to file + stdout (journald)
exec > >(tee -a "${LOG_FILE}") 2>&1

header "Repo Restore: ${TS}"
log "Target: ${TARGET} (dry_run=${DRY_RUN}, delete=${CLEAN_DELETE})"

# --- Preflight ---
check_root
acquire_lock "repo-restore"
require_cmd rsync
require_cmd stat
require_cmd flock

check_config_perms "${ENV_FILE}"
load_repo_env "${ENV_FILE}"

# --- Validate required env vars ---
: "${REPO_ROOT:?REPO_ROOT is missing in repo.env}"
: "${REPO_USER:?REPO_USER is missing in repo.env}"
: "${REPO_GROUP:?REPO_GROUP is missing in repo.env}"
: "${SOURCE_COMPOSE:?SOURCE_COMPOSE is missing in repo.env}"
: "${SOURCE_PROJECTS:?SOURCE_PROJECTS is missing in repo.env}"

# Optional policy (can be set in repo.env)
RUN_RETENTION_DAYS="${RUN_RETENTION_DAYS:-$RUN_RETENTION_DAYS_DEFAULT}"

# --- Safety stops ---
[[ "${REPO_ROOT}" != "/" ]] || critical "SAFETY STOP: REPO_ROOT is set to /"
require_under "/srv" "${REPO_ROOT}"
require_dir "${REPO_ROOT}"

require_under "/srv" "${SOURCE_COMPOSE}"
require_under "/srv" "${SOURCE_PROJECTS}"

# --- Metadata (no secrets) ---
write_env_summary "${RUN_DIR}" \
  "ts=${TS}" \
  "hostname=$(hostname -s)" \
  "script=repo-restore" \
  "target=${TARGET}" \
  "dry_run=${DRY_RUN}" \
  "delete=${CLEAN_DELETE}" \
  "repo_root=${REPO_ROOT}" \
  "source_compose=${SOURCE_COMPOSE}" \
  "source_projects=${SOURCE_PROJECTS}" \
  "run_retention_days=${RUN_RETENTION_DAYS}" \
  "rsync_version=$(rsync --version 2>/dev/null | head -n1 || echo unknown)"

# ============================================================
# Safety prompt (manual confirmation)
# ============================================================
confirm_execution() {
  header "Safety Confirmation"

  echo -e "${RED}WARNING:${NC} You are about to OVERWRITE host files from the repository."
  echo "  Repo:   ${REPO_ROOT}"
  echo "  Target: ${TARGET}"
  if [[ "${TARGET}" =~ ^(compose|projects|docker|all)$ ]] && [[ "${CLEAN_DELETE}" -eq 1 ]]; then
    echo -e "  Mode:   ${RED}--delete enabled${NC} (extra destructive)"
  fi
  echo ""

  # Encourage preview
  if [[ "${DRY_RUN}" -eq 0 ]]; then
    echo "Tip: Run with --dry-run first to preview changes."
  fi

  # Strong confirmation
  local expected="RESTORE"
  read -r -p "Type ${expected} to proceed: " reply
  if [[ "${reply}" != "${expected}" ]]; then
    echo "Aborted by user."
    exit 1
  fi
}

# Always require manual confirmation for real runs (not for dry-run).
if [[ "${DRY_RUN}" -eq 0 ]]; then
  confirm_execution
else
  header "Dry-run mode"
  log "No changes will be applied."
fi

# ============================================================
# Helpers
# ============================================================

# Base rsync args for restore operations (repo -> host)
rsync_restore() {
  local src="$1"
  local dst="$2"

  require_nonempty "$src" "src"
  require_nonempty "$dst" "dst"

  local -a args=(
    rsync
    -a
    --safe-links
    --numeric-ids
    --itemize-changes
    --info=FLIST2,STATS2
  )

  # For docker restore we allow optional --delete
  if [[ "${CLEAN_DELETE}" -eq 1 ]]; then
    args+=(--delete --delete-delay)
  fi

  if [[ "${DRY_RUN}" -eq 1 ]]; then
    args+=(--dry-run)
  fi

  [[ "$src" == */ ]] || src="${src}/"
  [[ "$dst" == */ ]] || dst="${dst}/"

  "${args[@]}" "$src" "$dst" 2>&1 | tee -a "${CHANGES_FILE}"
}

restore_file() {
  local repo_rel="$1"  # relative to REPO_ROOT
  local sys_path="$2"
  local owner="$3"
  local perms="$4"

  require_nonempty "$repo_rel" "repo_rel"
  require_nonempty "$sys_path" "sys_path"
  require_nonempty "$owner" "owner"
  require_nonempty "$perms" "perms"

  local repo_path="${REPO_ROOT}/${repo_rel}"

  if [[ -f "$repo_path" ]]; then
    if [[ "${DRY_RUN}" -eq 1 ]]; then
      echo " DRY: would restore: ${sys_path} (${owner}, ${perms})"
      return
    fi

    mkdir -p "$(dirname "$sys_path")"
    cp -a -- "$repo_path" "$sys_path"
    chown "$owner" "$sys_path"
    chmod "$perms" "$sys_path"
    echo " + Restored: $sys_path ($owner, $perms)"
  else
    echo -e " ${YELLOW}! Skipped (missing in repo): ${repo_rel}${NC}"
  fi
}

# Wrappers (mirror repo-sync structure)
etc_restore() {
  local rel_dir="$1"
  shift
  local repo_base="host/etc"
  local sys_base="/etc"

  if [[ "$rel_dir" != "." ]]; then
    repo_base="host/etc/${rel_dir}"
    sys_base="/etc/${rel_dir}"
  fi

  for f in "$@"; do
    restore_file "${repo_base}/${f}" "${sys_base}/${f}" "root:root" "644"
  done
}

systemd_restore() {
  local rel_dir="$1"
  shift
  local repo_base="host/etc/systemd"
  local sys_base="/etc/systemd"

  if [[ "$rel_dir" != "." ]]; then
    repo_base="host/etc/systemd/${rel_dir}"
    sys_base="/etc/systemd/${rel_dir}"
  fi

  for f in "$@"; do
    restore_file "${repo_base}/${f}" "${sys_base}/${f}" "root:root" "644"
  done
}

units_restore() {
  local rel_dir="$1"
  shift
  local repo_base="host/etc/systemd/system"
  local sys_base="/etc/systemd/system"

  if [[ "$rel_dir" != "." ]]; then
    repo_base="host/etc/systemd/system/${rel_dir}"
    sys_base="/etc/systemd/system/${rel_dir}"
  fi

  for f in "$@"; do
    restore_file "${repo_base}/${f}" "${sys_base}/${f}" "root:root" "644"
  done
}

# ============================================================
# Restore modules
# ============================================================

restore_compose() {
  header "Restore: Docker Compose"
  local src="${REPO_ROOT}/docker/compose"
  local dst="${SOURCE_COMPOSE}"

  if [[ ! -d "$src" ]]; then
    warn "Repo compose directory missing: $src (skipping)"
    return
  fi
  if [[ -z "$dst" ]] || [[ ! -d "$dst" ]]; then
    warn "SOURCE_COMPOSE missing/unset: $dst (skipping)"
    return
  fi

  # Compose/projects are owned by REPO_USER on the host.
  if [[ "${DRY_RUN}" -eq 0 ]]; then
    mkdir -p "$dst"
  fi

  log "Rsync: ${src} -> ${dst}"
  rsync_restore "$src" "$dst"

  if [[ "${DRY_RUN}" -eq 0 ]]; then
    chown -R "${REPO_USER}:${REPO_GROUP}" "$dst"
  else
    log "DRY: would chown -R ${REPO_USER}:${REPO_GROUP} ${dst}"
  fi

  log "Compose files updated."
}

restore_projects() {
  header "Restore: Docker Projects"
  local src="${REPO_ROOT}/docker/projects"
  local dst="${SOURCE_PROJECTS}"

  if [[ ! -d "$src" ]]; then
    warn "Repo projects directory missing: $src (skipping)"
    return
  fi
  if [[ -z "$dst" ]] || [[ ! -d "$dst" ]]; then
    warn "SOURCE_PROJECTS missing/unset: $dst (skipping)"
    return
  fi

  if [[ "${DRY_RUN}" -eq 0 ]]; then
    mkdir -p "$dst"
  fi

  log "Rsync: ${src} -> ${dst}"
  rsync_restore "$src" "$dst"

  if [[ "${DRY_RUN}" -eq 0 ]]; then
    chown -R "${REPO_USER}:${REPO_GROUP}" "$dst"
  else
    log "DRY: would chown -R ${REPO_USER}:${REPO_GROUP} ${dst}"
  fi

  log "Projects files updated."
}

restore_docker() {
  header "Restore: Docker (compose + projects)"
  restore_compose
  restore_projects
}

restore_etc() {
  header "Restore: Host /etc"
  etc_restore "ssh" sshd_config ssh_config
  etc_restore "fail2ban" jail.local
  etc_restore "ufw" ufw.conf user.rules
  etc_restore "docker" daemon.json
}

restore_systemd() {
  header "Restore: Host /etc/systemd"
  systemd_restore "." journald.conf
}

restore_units() {
  header "Restore: Host /etc/systemd/system"
  units_restore "." restic-backup.service restic-backup.timer

  if [[ "${DRY_RUN}" -eq 1 ]]; then
    log "DRY: would run: systemctl daemon-reload"
  else
    systemctl daemon-reload
    log "systemd daemon-reload done."
  fi
}

restore_host() {
  header "Restore: Host (etc + systemd + units)"
  restore_etc
  restore_systemd
  restore_units
}

# ============================================================
# Execution
# ============================================================

case "$TARGET" in
  compose)   restore_compose ;;
  projects)  restore_projects ;;
  docker)    restore_docker ;;
  etc)       restore_etc ;;
  systemd)   restore_systemd ;;
  units)     restore_units ;;
  host)      restore_host ;;
  all)       restore_docker; restore_host ;;
  *)
    error "Invalid target: ${TARGET}"
    usage
    exit 1
    ;;
esac

# Local retention for run dirs
perform_retention "${RUNS_BASE_DIR}" "${RUN_RETENTION_DAYS}"

echo ""
finish_run "${RUN_DIR}"

Nadawanie uprawnień

Uprawnienia 0750 pozwalają uruchamiać skrypt tylko rootowi i zaufanej grupie (np. sudo), a jednocześnie blokują dostęp dla pozostałych użytkowników.

sudo chown root:root /srv/config/scripts/repo-restore.sh
sudo chmod 0750 /srv/config/scripts/repo-restore.sh

Uruchomienie odtwarzania

Skrypt uruchamiamy z katalogu skryptów.

Argument --dry-run działa w każdym trybie i wykonuje test odtwarzania bez zapisu zmian.

cd /srv/config/scripts
sudo ./repo-restore.sh --dry-run all

Uruchomienie bez --dry-run wymaga potwierdzenia wpisaniem RESTORE.

cd /srv/config/scripts
sudo ./repo-restore.sh all

Tryb --delete

Argument --delete włącza kasowanie plików w katalogach Dockera, które nie istnieją w repozytorium.

Danger

--delete usuwa lokalne pliki nieobecne w repozytorium, w tym pliki .env w katalogach Docker Compose i projektów.

cd /srv/config/scripts
sudo ./repo-restore.sh --dry-run --delete docker

Tryby pracy

Skrypt przyjmuje jeden argument określający zakres odtwarzania. Tryb domyślny: host.

Odtworzenie Docker Compose

sudo ./repo-restore.sh compose

Odtworzenie projektów Dockera

sudo ./repo-restore.sh projects

Odtworzenie Dockera

sudo ./repo-restore.sh docker

Odtworzenie /etc

sudo ./repo-restore.sh etc

Odtworzenie /etc/systemd

sudo ./repo-restore.sh systemd

Odtworzenie jednostek systemd

sudo ./repo-restore.sh units

Odtworzenie hosta

Odtworzenie konfiguracji hosta:

  • /etc
  • /etc/systemd
  • /etc/systemd/system
sudo ./repo-restore.sh host

Odtworzenie pełne

sudo ./repo-restore.sh all

Katalog uruchomienia

Każde uruchomienie tworzy katalog w /srv/backups/repo/runs/20YYYYMMDD_HHMM/.

W katalogu zapisywane są:

  • restore.log – pełny log uruchomienia
  • changes.txt – zestawienie zmian z rsync --itemize-changes
  • env-summary.txt – podsumowanie parametrów uruchomienia bez sekretów
  • .SUCCESS lub .FAILED – znacznik wyniku
  • exit-code.txt – kod zakończenia przy błędzie