new backup & restore code
This commit is contained in:
parent
93dd619080
commit
79a40658bf
|
@ -0,0 +1,140 @@
|
||||||
|
require_dependency "export/exporter"
|
||||||
|
require_dependency "import/importer"
|
||||||
|
|
||||||
|
module BackupRestore
|
||||||
|
|
||||||
|
class OperationRunningError < RuntimeError; end
|
||||||
|
|
||||||
|
DUMP_FILE = "dump.sql"
|
||||||
|
METADATA_FILE = "meta.json"
|
||||||
|
|
||||||
|
def self.backup!(user_id, publish_to_message_bus = false)
|
||||||
|
exporter = Export::Exporter.new(user_id, publish_to_message_bus)
|
||||||
|
start! exporter
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.restore!(user_id, filename, publish_to_message_bus = false)
|
||||||
|
importer = Import::Importer.new(user_id, filename, publish_to_message_bus)
|
||||||
|
start! importer
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.rollback!
|
||||||
|
raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running?
|
||||||
|
if can_rollback?
|
||||||
|
rename_schema("backup", "public")
|
||||||
|
establish_app
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.cancel!
|
||||||
|
set_shutdown_signal!
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.mark_as_running!
|
||||||
|
# TODO: should acquire a lock and raise an exception if already running!
|
||||||
|
$redis.set(running_key, "1")
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.is_operation_running?
|
||||||
|
!!$redis.get(running_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.mark_as_not_running!
|
||||||
|
$redis.del(running_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.should_shutdown?
|
||||||
|
!!$redis.get(shutdown_signal_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.can_rollback?
|
||||||
|
backup_tables_count > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.operations_status
|
||||||
|
{
|
||||||
|
is_operation_running: is_operation_running?,
|
||||||
|
can_rollback: can_rollback?,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.current_version
|
||||||
|
ActiveRecord::Migrator.current_version
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.can_rollback?
|
||||||
|
User.exec_sql("SELECT 1 FROM pg_namespace WHERE nspname = 'backup'").count > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.rename_schema(old_name, new_name)
|
||||||
|
sql = <<-SQL
|
||||||
|
BEGIN;
|
||||||
|
DROP SCHEMA IF EXISTS #{new_name} CASCADE;
|
||||||
|
ALTER SCHEMA #{old_name} RENAME TO #{new_name};
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
User.exec_sql(sql)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.running_key
|
||||||
|
"backup_restore_operation_is_running"
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.shutdown_signal_key
|
||||||
|
"backup_restore_operation_should_shutdown"
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.set_shutdown_signal!
|
||||||
|
$redis.set(shutdown_signal_key, "1")
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.clear_shutdown_signal!
|
||||||
|
$redis.del(shutdown_signal_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.start!(runner)
|
||||||
|
child = fork do
|
||||||
|
begin
|
||||||
|
after_fork
|
||||||
|
runner.run
|
||||||
|
rescue Exception => e
|
||||||
|
puts "--------------------------------------------"
|
||||||
|
puts "---------------- EXCEPTION -----------------"
|
||||||
|
puts e.message
|
||||||
|
puts e.backtrace.join("\n")
|
||||||
|
puts "--------------------------------------------"
|
||||||
|
ensure
|
||||||
|
begin
|
||||||
|
clear_shutdown_signal!
|
||||||
|
rescue Exception => e
|
||||||
|
puts "============================================"
|
||||||
|
puts "================ EXCEPTION ================="
|
||||||
|
puts e.message
|
||||||
|
puts e.backtrace.join("\n")
|
||||||
|
puts "============================================"
|
||||||
|
ensure
|
||||||
|
exit!(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Process.detach(child)
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.after_fork
|
||||||
|
$redis.client.reconnect
|
||||||
|
Rails.cache.reconnect
|
||||||
|
MessageBus.after_fork
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.backup_tables_count
|
||||||
|
User.exec_sql("SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = 'backup'")[0]['count'].to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,302 @@
|
||||||
|
module Export
|
||||||
|
|
||||||
|
class Exporter
|
||||||
|
|
||||||
|
def initialize(user_id, publish_to_message_bus = false)
|
||||||
|
@user_id, @publish_to_message_bus = user_id, publish_to_message_bus
|
||||||
|
|
||||||
|
ensure_no_operation_is_running
|
||||||
|
ensure_we_have_a_user
|
||||||
|
|
||||||
|
initialize_state
|
||||||
|
end
|
||||||
|
|
||||||
|
def run
|
||||||
|
log "[STARTED]"
|
||||||
|
log "'#{@user.username}' has started the backup!"
|
||||||
|
|
||||||
|
mark_export_as_running
|
||||||
|
|
||||||
|
listen_for_shutdown_signal
|
||||||
|
|
||||||
|
enable_readonly_mode
|
||||||
|
|
||||||
|
pause_sidekiq
|
||||||
|
wait_for_sidekiq
|
||||||
|
|
||||||
|
ensure_directory_exists(@tmp_directory)
|
||||||
|
|
||||||
|
write_metadata
|
||||||
|
|
||||||
|
dump_public_schema
|
||||||
|
|
||||||
|
update_dump
|
||||||
|
|
||||||
|
log "Finalizing backup..."
|
||||||
|
|
||||||
|
ensure_directory_exists(@archive_directory)
|
||||||
|
|
||||||
|
create_archive
|
||||||
|
|
||||||
|
notify_user
|
||||||
|
rescue SystemExit
|
||||||
|
log "Backup process was cancelled!"
|
||||||
|
rescue Exception => ex
|
||||||
|
log "EXCEPTION: " + ex.message
|
||||||
|
log ex.backtrace.join("\n")
|
||||||
|
else
|
||||||
|
@success = true
|
||||||
|
"#{@archive_basename}.tar.gz"
|
||||||
|
ensure
|
||||||
|
clean_up
|
||||||
|
@success ? log("[SUCCESS]") : log("[FAILED]")
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def ensure_no_operation_is_running
|
||||||
|
raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running?
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_we_have_a_user
|
||||||
|
@user = User.where(id: @user_id).first
|
||||||
|
raise Discourse::InvalidParameters.new(:user_id) unless @user
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_state
|
||||||
|
@success = false
|
||||||
|
@current_db = RailsMultisite::ConnectionManagement.current_db
|
||||||
|
@timestamp = Time.now.strftime("%Y-%m-%d-%H%M%S")
|
||||||
|
@tmp_directory = File.join(Rails.root, "tmp", "backups", @current_db, @timestamp)
|
||||||
|
@dump_filename = File.join(@tmp_directory, BackupRestore::DUMP_FILE)
|
||||||
|
@meta_filename = File.join(@tmp_directory, BackupRestore::METADATA_FILE)
|
||||||
|
@archive_directory = File.join(Rails.root, "public", "backups", @current_db)
|
||||||
|
@archive_basename = File.join(@archive_directory, @timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
|
def listen_for_shutdown_signal
|
||||||
|
Thread.new do
|
||||||
|
while BackupRestore.is_operation_running?
|
||||||
|
exit if BackupRestore.should_shutdown?
|
||||||
|
sleep 0.1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_export_as_running
|
||||||
|
log "Marking backup as running..."
|
||||||
|
BackupRestore.mark_as_running!
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable_readonly_mode
|
||||||
|
log "Enabling readonly mode..."
|
||||||
|
Discourse.enable_readonly_mode
|
||||||
|
end
|
||||||
|
|
||||||
|
def pause_sidekiq
|
||||||
|
log "Pausing sidekiq..."
|
||||||
|
Sidekiq.pause!
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_for_sidekiq
|
||||||
|
log "Waiting for sidekiq to finish running jobs..."
|
||||||
|
iterations = 0
|
||||||
|
while (running = Sidekiq::Queue.all.map(&:size).sum) > 0
|
||||||
|
log " Waiting for #{running} jobs..."
|
||||||
|
sleep 2
|
||||||
|
iterations += 1
|
||||||
|
raise "Sidekiq did not finish running all the jobs in the allowed time!" if iterations >= 15
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_metadata
|
||||||
|
log "Writing metadata to '#{@meta_filename}'..."
|
||||||
|
metadata = {
|
||||||
|
source: "discourse",
|
||||||
|
version: BackupRestore.current_version
|
||||||
|
}
|
||||||
|
File.write(@meta_filename, metadata.to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dump_public_schema
|
||||||
|
log "Dumping the public schema of the database..."
|
||||||
|
|
||||||
|
pg_dump_command = build_pg_dump_command
|
||||||
|
log "Running: #{pg_dump_command}"
|
||||||
|
|
||||||
|
logs = Queue.new
|
||||||
|
pg_dump_running = true
|
||||||
|
|
||||||
|
Thread.new do
|
||||||
|
while pg_dump_running
|
||||||
|
message = logs.pop.strip
|
||||||
|
log(message) unless message.blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.popen("#{pg_dump_command} 2>&1") do |pipe|
|
||||||
|
begin
|
||||||
|
while line = pipe.readline
|
||||||
|
logs << line
|
||||||
|
end
|
||||||
|
rescue EOFError
|
||||||
|
# finished reading...
|
||||||
|
ensure
|
||||||
|
pg_dump_running = false
|
||||||
|
logs << ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
raise "pg_dump failed" unless $?.success?
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_pg_dump_command
|
||||||
|
db_conf = Rails.configuration.database_configuration[Rails.env]
|
||||||
|
host = db_conf["host"] || "localhost"
|
||||||
|
password = db_conf["password"]
|
||||||
|
username = db_conf["username"] || "postgres"
|
||||||
|
database = db_conf["database"]
|
||||||
|
|
||||||
|
[ "PGPASSWORD=#{password}", # pass the password to pg_dump
|
||||||
|
"pg_dump", # the pg_dump command
|
||||||
|
"--exclude-schema=backup,restore", # exclude both backup & restore schemes
|
||||||
|
"--file='#{@dump_filename}'", # output to the dump.sql file
|
||||||
|
"--no-owner", # do not output commands to set ownership of objects
|
||||||
|
"--no-privileges", # prevent dumping of access privileges
|
||||||
|
"--verbose", # specifies verbose mode
|
||||||
|
"--host=#{host}", # the hostname to connect to
|
||||||
|
"--username=#{username}", # the username to connect as
|
||||||
|
database # the name of the database to dump
|
||||||
|
].join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_dump
|
||||||
|
log "Updating dump for more awesomeness..."
|
||||||
|
|
||||||
|
sed_command = build_sed_command
|
||||||
|
|
||||||
|
log "Running: #{sed_command}"
|
||||||
|
|
||||||
|
`#{sed_command}`
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_sed_command
|
||||||
|
# in order to limit the downtime when restoring as much as possible
|
||||||
|
# we force the restoration to happen in the "restore" schema
|
||||||
|
|
||||||
|
# during the restoration, this make sure we
|
||||||
|
# - drop the "restore" schema if it exists
|
||||||
|
# - create the "restore" schema
|
||||||
|
# - prepend the "restore" schema into the search_path
|
||||||
|
|
||||||
|
regexp = "^SET search_path = public, pg_catalog;$"
|
||||||
|
|
||||||
|
replacement = [ "DROP SCHEMA IF EXISTS restore CASCADE;",
|
||||||
|
"CREATE SCHEMA restore;",
|
||||||
|
"SET search_path = restore, public, pg_catalog;",
|
||||||
|
].join("\\n")
|
||||||
|
|
||||||
|
# we only want to replace the VERY first occurence of the search_path command
|
||||||
|
expression = "0,/#{regexp}/s//#{replacement}/"
|
||||||
|
|
||||||
|
# I tried to use the --in-place argument but it was SLOOOWWWWwwwwww
|
||||||
|
# so I output the result into another file and rename it back afterwards
|
||||||
|
[ "sed --expression='#{expression}' < #{@dump_filename} > #{@dump_filename}.tmp",
|
||||||
|
"&&",
|
||||||
|
"mv #{@dump_filename}.tmp #{@dump_filename}",
|
||||||
|
].join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_archive
|
||||||
|
log "Creating archive: #{File.basename(@archive_basename)}.tar.gz"
|
||||||
|
|
||||||
|
tar_filename = "#{@archive_basename}.tar"
|
||||||
|
|
||||||
|
log "Making sure archive does not already exist..."
|
||||||
|
`rm -f #{tar_filename}`
|
||||||
|
`rm -f #{tar_filename}.gz`
|
||||||
|
|
||||||
|
log "Creating empty archive..."
|
||||||
|
`tar --create --file #{tar_filename} --files-from /dev/null`
|
||||||
|
|
||||||
|
log "Archiving metadata..."
|
||||||
|
FileUtils.cd(File.dirname(@meta_filename)) do
|
||||||
|
`tar --append --file #{tar_filename} #{File.basename(@meta_filename)}`
|
||||||
|
end
|
||||||
|
|
||||||
|
log "Archiving data dump..."
|
||||||
|
FileUtils.cd(File.dirname(@dump_filename)) do
|
||||||
|
`tar --append --file #{tar_filename} #{File.basename(@dump_filename)}`
|
||||||
|
end
|
||||||
|
|
||||||
|
upload_directory = "uploads/" + @current_db
|
||||||
|
|
||||||
|
if Dir[upload_directory].present?
|
||||||
|
|
||||||
|
log "Archiving uploads..."
|
||||||
|
FileUtils.cd(File.join(Rails.root, "public")) do
|
||||||
|
`tar --append --file #{tar_filename} #{upload_directory}`
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
log "Gzipping archive..."
|
||||||
|
`gzip #{tar_filename}`
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_user
|
||||||
|
log "Notifying '#{@user.username}' of the success of the backup..."
|
||||||
|
# NOTE: will only notify if @user != Discourse.site_contact_user
|
||||||
|
SystemMessage.create(@user, :export_succeeded)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_up
|
||||||
|
log "Cleaning stuff up..."
|
||||||
|
remove_tmp_directory
|
||||||
|
unpause_sidekiq
|
||||||
|
disable_readonly_mode
|
||||||
|
mark_export_as_not_running
|
||||||
|
log "Finished!"
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_tmp_directory
|
||||||
|
log "Removing tmp '#{@tmp_directory}' directory..."
|
||||||
|
FileUtils.rm_rf(@tmp_directory) if Dir[@tmp_directory].present?
|
||||||
|
rescue
|
||||||
|
log "Something went wrong while removing the following tmp directory: #{@tmp_directory}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def unpause_sidekiq
|
||||||
|
log "Unpausing sidekiq..."
|
||||||
|
Sidekiq.unpause!
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable_readonly_mode
|
||||||
|
log "Disabling readonly mode..."
|
||||||
|
Discourse.disable_readonly_mode
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_export_as_not_running
|
||||||
|
log "Marking backup as finished..."
|
||||||
|
BackupRestore.mark_as_not_running!
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_directory_exists(directory)
|
||||||
|
log "Making sure '#{directory}' exists..."
|
||||||
|
FileUtils.mkdir_p(directory)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log(message)
|
||||||
|
puts(message) rescue nil
|
||||||
|
publish_log(message) rescue nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish_log(message)
|
||||||
|
return unless @publish_to_message_bus
|
||||||
|
data = { timestamp: Time.now, operation: "backup", message: message }
|
||||||
|
MessageBus.publish("/admin/backups/logs", data, user_ids: [@user_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,326 @@
|
||||||
|
module Import
|
||||||
|
|
||||||
|
class ImportDisabledError < RuntimeError; end
|
||||||
|
class FilenameMissingError < RuntimeError; end
|
||||||
|
|
||||||
|
class Importer
|
||||||
|
|
||||||
|
def initialize(user_id, filename, publish_to_message_bus = false)
|
||||||
|
@user_id, @filename, @publish_to_message_bus = user_id, filename, publish_to_message_bus
|
||||||
|
|
||||||
|
ensure_import_is_enabled
|
||||||
|
ensure_no_operation_is_running
|
||||||
|
ensure_we_have_a_user
|
||||||
|
ensure_we_have_a_filename
|
||||||
|
|
||||||
|
initialize_state
|
||||||
|
end
|
||||||
|
|
||||||
|
def run
|
||||||
|
log "[STARTED]"
|
||||||
|
log "'#{@user_info[:username]}' has started the restore!"
|
||||||
|
|
||||||
|
mark_import_as_running
|
||||||
|
|
||||||
|
listen_for_shutdown_signal
|
||||||
|
|
||||||
|
enable_readonly_mode
|
||||||
|
|
||||||
|
pause_sidekiq
|
||||||
|
wait_for_sidekiq
|
||||||
|
|
||||||
|
ensure_directory_exists(@tmp_directory)
|
||||||
|
|
||||||
|
copy_archive_to_tmp_directory
|
||||||
|
unzip_archive
|
||||||
|
|
||||||
|
extract_metadata
|
||||||
|
validate_metadata
|
||||||
|
|
||||||
|
extract_dump
|
||||||
|
|
||||||
|
restore_dump
|
||||||
|
|
||||||
|
#----------- CRITICAL --------------
|
||||||
|
switch_schema!
|
||||||
|
#----------- CRITICAL --------------
|
||||||
|
|
||||||
|
log "Finalizing restore..."
|
||||||
|
|
||||||
|
migrate_database
|
||||||
|
|
||||||
|
reconnect_database
|
||||||
|
|
||||||
|
extract_uploads
|
||||||
|
|
||||||
|
notify_user
|
||||||
|
rescue SystemExit
|
||||||
|
log "Restore process was cancelled!"
|
||||||
|
rollback
|
||||||
|
rescue Exception => ex
|
||||||
|
log "EXCEPTION: " + ex.message
|
||||||
|
log ex.backtrace.join("\n")
|
||||||
|
rollback
|
||||||
|
else
|
||||||
|
@success = true
|
||||||
|
ensure
|
||||||
|
clean_up
|
||||||
|
@success ? log("[SUCCESS]") : log("[FAILED]")
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def ensure_import_is_enabled
|
||||||
|
raise Import::ImportDisabledError unless SiteSetting.allow_import?
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_no_operation_is_running
|
||||||
|
raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running?
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_we_have_a_user
|
||||||
|
user = User.where(id: @user_id).first
|
||||||
|
raise Discourse::InvalidParameters.new(:user_id) unless user
|
||||||
|
# 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 Import::FilenameMissingError if @filename.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_state
|
||||||
|
@success = false
|
||||||
|
@current_db = RailsMultisite::ConnectionManagement.current_db
|
||||||
|
@current_version = BackupRestore.current_version
|
||||||
|
@timestamp = Time.now.strftime("%Y-%m-%d-%H%M%S")
|
||||||
|
@tmp_directory = File.join(Rails.root, "tmp", "restores", @current_db, @timestamp)
|
||||||
|
@archive_filename = File.join(@tmp_directory, @filename)
|
||||||
|
@tar_filename = @archive_filename[0...-3]
|
||||||
|
@meta_filename = File.join(@tmp_directory, BackupRestore::METADATA_FILE)
|
||||||
|
@dump_filename = File.join(@tmp_directory, BackupRestore::DUMP_FILE)
|
||||||
|
end
|
||||||
|
|
||||||
|
def listen_for_shutdown_signal
|
||||||
|
Thread.new do
|
||||||
|
while BackupRestore.is_operation_running?
|
||||||
|
exit if BackupRestore.should_shutdown?
|
||||||
|
sleep 0.1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_import_as_running
|
||||||
|
log "Marking restore as running..."
|
||||||
|
BackupRestore.mark_as_running!
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable_readonly_mode
|
||||||
|
log "Enabling readonly mode..."
|
||||||
|
Discourse.enable_readonly_mode
|
||||||
|
end
|
||||||
|
|
||||||
|
def pause_sidekiq
|
||||||
|
log "Pausing sidekiq..."
|
||||||
|
Sidekiq.pause!
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_for_sidekiq
|
||||||
|
log "Waiting for sidekiq to finish running jobs..."
|
||||||
|
iterations = 0
|
||||||
|
while (running = Sidekiq::Queue.all.map(&:size).sum) > 0
|
||||||
|
log " Waiting for #{running} jobs..."
|
||||||
|
sleep 5
|
||||||
|
iterations += 1
|
||||||
|
raise "Sidekiq did not finish running all the jobs in the allowed time!" if iterations >= 20
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def copy_archive_to_tmp_directory
|
||||||
|
log "Copying archive to tmp directory..."
|
||||||
|
source = File.join(Backup.base_directory, @filename)
|
||||||
|
`cp #{source} #{@archive_filename}`
|
||||||
|
end
|
||||||
|
|
||||||
|
def unzip_archive
|
||||||
|
log "Unzipping archive..."
|
||||||
|
FileUtils.cd(@tmp_directory) { `gzip --decompress #{@archive_filename}` }
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_metadata
|
||||||
|
log "Extracting metadata file..."
|
||||||
|
FileUtils.cd(@tmp_directory) { `tar --extract --file #{@tar_filename} #{BackupRestore::METADATA_FILE}` }
|
||||||
|
@metadata = Oj.load_file(@meta_filename)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_metadata
|
||||||
|
log "Validating metadata..."
|
||||||
|
log " Current version: #{@current_version}"
|
||||||
|
log " Restored version: #{@metadata["version"]}"
|
||||||
|
|
||||||
|
error = "You're trying to import a more recent version of the schema. You should migrate first!"
|
||||||
|
raise error if @metadata["version"] > @current_version
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_dump
|
||||||
|
log "Extracting dump file..."
|
||||||
|
FileUtils.cd(@tmp_directory) { `tar --extract --file #{@tar_filename} #{BackupRestore::DUMP_FILE}` }
|
||||||
|
end
|
||||||
|
|
||||||
|
def restore_dump
|
||||||
|
log "Restoring dump file... (can be quite long)"
|
||||||
|
|
||||||
|
psql_command = build_psql_command
|
||||||
|
log "Running: #{psql_command}"
|
||||||
|
|
||||||
|
logs = Queue.new
|
||||||
|
psql_running = true
|
||||||
|
has_error = false
|
||||||
|
|
||||||
|
Thread.new do
|
||||||
|
while psql_running
|
||||||
|
message = logs.pop.strip
|
||||||
|
has_error ||= (message =~ /ERROR:/)
|
||||||
|
log(message) unless message.blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.popen("#{psql_command} 2>&1") do |pipe|
|
||||||
|
begin
|
||||||
|
while line = pipe.readline
|
||||||
|
logs << line
|
||||||
|
end
|
||||||
|
rescue EOFError
|
||||||
|
# finished reading...
|
||||||
|
ensure
|
||||||
|
psql_running = false
|
||||||
|
logs << ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# psql does not return a valid exit code when an error happens
|
||||||
|
raise "psql failed" if has_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_psql_command
|
||||||
|
db_conf = Rails.configuration.database_configuration[Rails.env]
|
||||||
|
host = db_conf["host"] || "localhost"
|
||||||
|
password = db_conf["password"]
|
||||||
|
username = db_conf["username"] || "postgres"
|
||||||
|
database = db_conf["database"]
|
||||||
|
|
||||||
|
[ "PGPASSWORD=#{password}", # pass the password to psql
|
||||||
|
"psql", # the psql command
|
||||||
|
"--dbname='#{database}'", # connect to database *dbname*
|
||||||
|
"--file='#{@dump_filename}'", # read the dump
|
||||||
|
"--single-transaction", # all or nothing (also runs COPY commands faster)
|
||||||
|
"--host=#{host}", # the hostname to connect to
|
||||||
|
"--username=#{username}" # the username to connect as
|
||||||
|
].join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def switch_schema!
|
||||||
|
log "Switching schemas..."
|
||||||
|
|
||||||
|
sql = <<-SQL
|
||||||
|
BEGIN;
|
||||||
|
DROP SCHEMA IF EXISTS backup CASCADE;
|
||||||
|
ALTER SCHEMA public RENAME TO backup;
|
||||||
|
ALTER SCHEMA restore RENAME TO public;
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
User.exec_sql(sql)
|
||||||
|
end
|
||||||
|
|
||||||
|
def migrate_database
|
||||||
|
log "Migrating the database..."
|
||||||
|
Discourse::Application.load_tasks
|
||||||
|
ENV["VERSION"] = @current_version.to_s
|
||||||
|
Rake::Task["db:migrate:up"].invoke
|
||||||
|
end
|
||||||
|
|
||||||
|
def reconnect_database
|
||||||
|
log "Reconnecting to the database..."
|
||||||
|
ActiveRecord::Base.establish_connection
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_uploads
|
||||||
|
log "Extracting uploads..."
|
||||||
|
if `tar --list --file #{@tar_filename} | grep 'uploads/'`.present?
|
||||||
|
FileUtils.cd(File.join(Rails.root, "public")) do
|
||||||
|
`tar --extract --keep-newer-files --file #{@tar_filename} uploads/`
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_user
|
||||||
|
if user = User.where(email: @user_info[:email]).first
|
||||||
|
log "Notifying '#{user.username}' of the success of the restore..."
|
||||||
|
# NOTE: will only notify if user != Discourse.site_contact_user
|
||||||
|
SystemMessage.create(user, :import_succeeded)
|
||||||
|
else
|
||||||
|
log "Could not send notification to '#{@user_info[:username]}' (#{@user_info[:email]}), because the user does not exists..."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def rollback
|
||||||
|
log "Trying to rollback..."
|
||||||
|
if BackupRestore.can_rollback?
|
||||||
|
log "Rolling back to previous working state..."
|
||||||
|
BackupRestore.rename_schema("backup", "public")
|
||||||
|
else
|
||||||
|
log "No backup schema was created yet!"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_up
|
||||||
|
log "Cleaning stuff up..."
|
||||||
|
remove_tmp_directory
|
||||||
|
unpause_sidekiq
|
||||||
|
disable_readonly_mode
|
||||||
|
mark_import_as_not_running
|
||||||
|
log "Finished!"
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_tmp_directory
|
||||||
|
log "Removing tmp '#{@tmp_directory}' directory..."
|
||||||
|
FileUtils.rm_rf(@tmp_directory) if Dir[@tmp_directory].present?
|
||||||
|
rescue
|
||||||
|
log "Something went wrong while removing the following tmp directory: #{@tmp_directory}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def unpause_sidekiq
|
||||||
|
log "Unpausing sidekiq..."
|
||||||
|
Sidekiq.unpause!
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable_readonly_mode
|
||||||
|
log "Disabling readonly mode..."
|
||||||
|
Discourse.disable_readonly_mode
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_import_as_not_running
|
||||||
|
log "Marking restore as finished..."
|
||||||
|
BackupRestore.mark_as_not_running!
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_directory_exists(directory)
|
||||||
|
log "Making sure #{directory} exists..."
|
||||||
|
FileUtils.mkdir_p(directory)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log(message)
|
||||||
|
puts(message) rescue nil
|
||||||
|
publish_log(message) rescue nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish_log(message)
|
||||||
|
return unless @publish_to_message_bus
|
||||||
|
data = { timestamp: Time.now, operation: "restore", message: message }
|
||||||
|
MessageBus.publish("/admin/backups/logs", data, user_ids: [@user_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
Loading…
Reference in New Issue