Files
discourse/lib/backup_restore/restorer.rb
Bianca Nenciu 1f2efa7954 FEATURE: Add utilities for importing and exporting backups (#32992)
This commit introduces new features and utilities related to the backup
and restore system that make use of remote URLs:

- `discourse restore` accepts a URL to a backup file

- `discourse backup_url` generates a URL of a backup file (S3 only)

- `discourse import_backup_url` downloads a backup file from a URL to
the configured backup store

This can be used to move content between two Discourse instances by
backing up the entire site, copying the backup URL, importing or
restoring it on the other instance.
2025-06-11 15:44:10 +03:00

215 lines
5.9 KiB
Ruby

# frozen_string_literal: true
require "colored2"
module BackupRestore
RestoreDisabledError = Class.new(RuntimeError)
FilenameMissingError = Class.new(RuntimeError)
class Restorer
delegate :log, to: :@logger, private: true
attr_reader :success
def initialize(
user_id:,
filename:,
factory:,
disable_emails: true,
location: nil,
interactive: false
)
@user_id = user_id
@filename = filename
@factory = factory
@logger = factory.logger
@disable_emails = disable_emails
@interactive = interactive
ensure_restore_is_enabled
ensure_we_have_a_user
ensure_we_have_a_filename
@success = false
@current_db = RailsMultisite::ConnectionManagement.current_db
@system = factory.create_system_interface
@backup_file_handler = factory.create_backup_file_handler(@filename, @current_db, location)
@database_restorer = factory.create_database_restorer(@current_db)
@uploads_restorer = factory.create_uploads_restorer
end
def run
log "[STARTED]"
log "'#{@user_info[:username]}' has started the restore!"
# FIXME not atomic!
ensure_no_operation_is_running
@system.mark_restore_as_running
@system.listen_for_shutdown_signal
@filename, @tmp_directory, db_dump_path = @backup_file_handler.decompress
validate_backup_metadata
@system.enable_readonly_mode
@system.pause_sidekiq("restore")
@system.wait_for_sidekiq
@system.flush_redis
@system.clear_sidekiq_queues
@database_restorer.restore(db_dump_path, @interactive)
reload_site_settings
@system.disable_readonly_mode
clear_category_cache
clear_stats
reload_translations
restore_uploads
clear_emoji_cache
clear_theme_cache
after_restore_hook
rescue Compression::Strategy::ExtractFailed
log "ERROR: The uncompressed file is too big. Consider increasing the hidden " \
'"decompressed_backup_max_file_size_mb" setting.'
@database_restorer.rollback
rescue SystemExit
log "Restore process was cancelled!"
@database_restorer.rollback
rescue => ex
log "EXCEPTION: " + ex.message
log ex.backtrace.join("\n")
@database_restorer.rollback
else
@success = true
ensure
clean_up
notify_user
log "Finished!"
@success ? log("[SUCCESS]") : log("[FAILED]")
end
protected
def ensure_restore_is_enabled
return if Rails.env.development? || SiteSetting.allow_restore?
raise BackupRestore::RestoreDisabledError
end
def ensure_no_operation_is_running
raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running?
end
def ensure_we_have_a_user
user = User.find_by(id: @user_id)
raise Discourse::InvalidParameters.new(:user_id) if user.blank?
# keep some user data around to check them against the newly restored database
@user_info = { id: user.id, username: user.username, email: user.email }
end
def ensure_we_have_a_filename
raise BackupRestore::FilenameMissingError if @filename.nil?
end
def validate_backup_metadata
@factory.create_meta_data_handler(@filename, @tmp_directory).validate
end
def reload_site_settings
log "Reloading site settings..."
SiteSetting.refresh!
DiscourseEvent.trigger(:site_settings_restored)
if @disable_emails && SiteSetting.disable_emails == "no"
log "Disabling outgoing emails for non-staff users..."
user = User.find_by_email(@user_info[:email]) || Discourse.system_user
SiteSetting.set_and_log(:disable_emails, "non-staff", user)
end
end
def clear_category_cache
log "Clearing category cache..."
Category.reset_topic_ids_cache
Category.clear_subcategory_ids
end
def clear_emoji_cache
log "Clearing emoji cache..."
Emoji.clear_cache
rescue => ex
log "Something went wrong while clearing emoji cache.", ex
end
def reload_translations
log "Reloading translations..."
TranslationOverride.reload_all_overrides!
end
def restore_uploads
if @interactive
puts ""
puts "Attention! Pausing restore before uploads.".red.bold
puts "You can work on the restored database in a separate Rails console."
puts ""
puts "Press any key to continue with the restore.".bold
puts ""
STDIN.getch
end
@uploads_restorer.restore(@tmp_directory)
end
def notify_user
return if @success && @user_id == Discourse::SYSTEM_USER_ID
if user = User.find_by_email(@user_info[:email])
log "Notifying '#{user.username}' of the end of the restore..."
status = @success ? :restore_succeeded : :restore_failed
logs = Discourse::Utils.logs_markdown(@logger.logs, user: user)
post = SystemMessage.create_from_system_user(user, status, logs: logs)
else
log "Could not send notification to '#{@user_info[:username]}' " \
"(#{@user_info[:email]}), because the user does not exist."
end
rescue => ex
log "Something went wrong while notifying user.", ex
end
def clean_up
log "Cleaning stuff up..."
@database_restorer.clean_up
@backup_file_handler.clean_up
@system.unpause_sidekiq
@system.disable_readonly_mode if Discourse.readonly_mode?
@system.mark_restore_as_not_running
end
def clear_theme_cache
log "Clear theme cache"
ThemeField.force_recompilation!
Theme.expire_site_cache!
Stylesheet::Manager.cache.clear
rescue => ex
log "Something went wrong while clearing theme cache.", ex
end
def clear_stats
Discourse.stats.remove("missing_s3_uploads")
end
def after_restore_hook
log "Executing the after_restore_hook..."
DiscourseEvent.trigger(:restore_complete)
end
end
end