diff --git a/restore.sh b/restore.sh index 648638a..89bcc17 100755 --- a/restore.sh +++ b/restore.sh @@ -141,6 +141,86 @@ cmd_db() { rm -rf "$dest" } +container_running() { + [[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || echo false)" == "true" ]] +} + +container_health() { + docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \ + "$1" 2>/dev/null || echo unknown +} + +wait_for_container() { + local name="$1" deadline=$((SECONDS + DB_START_TIMEOUT)) health + while (( SECONDS < deadline )); do + if container_running "$name"; then + health="$(container_health "$name")" + case "$health" in + healthy|none) log "$name is up (health: $health)"; return 0 ;; + unhealthy) log "WARNING: $name reports unhealthy" ;; + esac + fi + sleep 3 + done + return 1 +} + +start_db() { + docker start "$DB_CONTAINER" >/dev/null 2>&1 || true + wait_for_container "$DB_CONTAINER" +} + +cmd_full() { + local staging dumpdir f dbname + resolve_archive + step "Full restore from archive $RESOLVED_ARCHIVE into $TARGET" + + if [[ -d "$TARGET" ]] && [[ -n "$(ls -A "$TARGET" 2>/dev/null)" ]] && [[ "$FORCE" != true ]]; then + die "$TARGET is not empty - pass --force to overwrite (existing data will be replaced)" + fi + + if [[ "$DRY_RUN" == true ]]; then + log "[DRY-RUN] would extract full ${ARCHIVE_TARGET_PATH} tree from ${REPO}::${RESOLVED_ARCHIVE} into $TARGET" + log "[DRY-RUN] would restore every *.sql dump under ${DUMP_SUBDIR}/ using root credentials" + log "[DRY-RUN] would start $DB_CONTAINER and wait for it to become healthy" + return 0 + fi + + staging="/tmp/restore-full-$$" + mkdir -p "$staging" + ( cd "$staging" && run_cmd borg extract --lock-wait 600 "${REPO}::${RESOLVED_ARCHIVE}" "${ARCHIVE_TARGET_PATH}" ) + + mkdir -p "$(dirname "$TARGET")" + rm -rf "${TARGET:?}"/* 2>/dev/null || true + mkdir -p "$TARGET" + run_cmd cp -a "${staging}/${ARCHIVE_TARGET_PATH}/." "$TARGET/" + rm -rf "$staging" + + detect_client + get_root_creds + + dumpdir="${TARGET}/${DUMP_SUBDIR}" + [[ -d "$dumpdir" ]] || die "no dump directory found after extract: $dumpdir" + + if [[ -f "${dumpdir}/00-users-and-grants.sql" ]]; then + step "Restoring users and grants" + docker exec -i -e MYSQL_PWD="$DB_PASS" "$DB_CONTAINER" \ + "$CLIENT_BIN" -u root < "${dumpdir}/00-users-and-grants.sql" + fi + + for f in "$dumpdir"/*.sql; do + [[ -e "$f" ]] || continue + dbname="$(basename "$f" .sql)" + [[ "$dbname" == "00-users-and-grants" ]] && continue + restore_single_db "$f" "$dbname" + done + + step "Starting $DB_CONTAINER" + start_db || die "CRITICAL: $DB_CONTAINER did not come up after restore" + + log "Full restore complete from archive $RESOLVED_ARCHIVE" +} + usage() { cat <<'EOF' Usage: diff --git a/tests/test_restore.sh b/tests/test_restore.sh index fe0dfb2..509238b 100755 --- a/tests/test_restore.sh +++ b/tests/test_restore.sh @@ -146,6 +146,46 @@ test_db_restore_aborts_on_wrong_confirmation() { rm -rf "$mockdir" } +test_full_restore_refuses_nonempty_target_without_force() { + local mockdir target rc + mockdir="$(mktemp -d)" + target="$(mktemp -d)" + touch "$target/existing-file" + setup_mock_bin "$mockdir" + set +e + PATH="$mockdir:$PATH" TARGET_OVERRIDE=1 bash -c ' + sed "s#^TARGET=\"/home/srv/files/content\"#TARGET=\"'"$target"'\"#; s@^ARCHIVE_TARGET_PATH=.*@ARCHIVE_TARGET_PATH=\"\${TARGET#/}\"@" "'"$RESTORE"'" > "'"$mockdir"'/restore_patched.sh" + bash "'"$mockdir"'/restore_patched.sh" full + ' >/dev/null 2>&1 + rc=$? + set -e + assert_eq "1" "$rc" "full mode refuses non-empty target without --force" + rm -rf "$mockdir" "$target" +} + +test_full_restore_dry_run_makes_no_calls() { + local mockdir target out + mockdir="$(mktemp -d)" + target="$(mktemp -d)" + setup_mock_bin "$mockdir" + out="$(PATH="$mockdir:$PATH" bash -c ' + sed "s#^TARGET=\"/home/srv/files/content\"#TARGET=\"'"$target"'\"#; s@^ARCHIVE_TARGET_PATH=.*@ARCHIVE_TARGET_PATH=\"\${TARGET#/}\"@" "'"$RESTORE"'" > "'"$mockdir"'/restore_patched.sh" + bash "'"$mockdir"'/restore_patched.sh" full --dry-run + ')" + assert_contains "$out" "DRY-RUN" "full dry-run prints DRY-RUN plan" + # cmd_full calls resolve_archive() (a borg list call) before checking + # DRY_RUN, same as cmd_db/cmd_file, so mock.log legitimately gets a + # "borg list" entry. What must NOT happen in dry-run is a borg extract, + # or any docker/db-client interaction. + if grep -qE "^(borg extract|docker|mysql|mariadb)" "$mockdir/mock.log" 2>/dev/null; then + echo "FAIL: full dry-run invoked borg extract or a docker/db-client mock binary" + FAILURES=$((FAILURES + 1)) + else + echo "PASS: full dry-run made no borg extract or docker/db-client calls" + fi + rm -rf "$mockdir" "$target" +} + test_help_exits_zero test_no_args_exits_one test_unknown_command_exits_one @@ -155,6 +195,8 @@ test_file_restore_dry_run_makes_no_borg_call test_db_restore_with_yes_runs_full_sequence test_db_restore_dry_run_skips_confirmation_and_calls test_db_restore_aborts_on_wrong_confirmation +test_full_restore_refuses_nonempty_target_without_force +test_full_restore_dry_run_makes_no_calls echo "-----" if [[ "$FAILURES" -gt 0 ]]; then