Gerhard Schlager 6b3e28216c
FEATURE: Allow pausing of restore before DB migration and uploads are restored ()
This can be helpful if you need to fix problems in the DB before the DB gets migrated as well as before uploads are restored.
2024-12-16 12:50:08 +01:00

239 lines
7.6 KiB
Ruby

# frozen_string_literal: true
module BackupRestore
DatabaseRestoreError = Class.new(RuntimeError)
class DatabaseRestorer
delegate :log, to: :@logger, private: true
MAIN_SCHEMA = "public"
BACKUP_SCHEMA = "backup"
DROP_BACKUP_SCHEMA_AFTER_DAYS = 7
def initialize(logger, current_db)
@logger = logger
@db_was_changed = false
@current_db = current_db
end
def restore(db_dump_path, interactive = false)
BackupRestore.move_tables_between_schemas(MAIN_SCHEMA, BACKUP_SCHEMA)
@db_dump_path = db_dump_path
@db_was_changed = true
create_missing_discourse_functions
restore_dump
pause_before_migration if interactive
migrate_database
reconnect_database
BackupMetadata.update_last_restore_date
end
def rollback
log "Trying to rollback..."
if @db_was_changed && BackupRestore.can_rollback?
log "Rolling back..."
BackupRestore.move_tables_between_schemas(BACKUP_SCHEMA, MAIN_SCHEMA)
else
log "There was no need to rollback"
end
end
def clean_up
drop_created_discourse_functions
end
def self.drop_backup_schema
ActiveRecord::Base.connection.drop_schema(BACKUP_SCHEMA) if backup_schema_dropable?
end
def self.all_migration_files
Dir[Rails.root.join(Migration::SafeMigrate.post_migration_path, "**/*.rb")] +
Dir[Rails.root.join("db/migrate/*.rb")] +
Dir[Rails.root.join("plugins/**", Migration::SafeMigrate.post_migration_path, "**/*.rb")] +
Dir[Rails.root.join("plugins/**", "db/migrate/*.rb")]
end
protected
def restore_dump
log "Restoring dump file... (this may take a while)"
logs = Queue.new
last_line = nil
psql_running = true
log_thread =
Thread.new do
RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
while psql_running || !logs.empty?
message = logs.pop.strip
log(message) if message.present?
end
end
IO.popen(restore_dump_command) do |pipe|
begin
while line = pipe.readline
logs << line
last_line = line
end
rescue EOFError
# finished reading...
ensure
psql_running = false
end
end
logs << ""
log_thread.join
if Process.last_status&.exitstatus != 0
raise DatabaseRestoreError.new("psql failed: #{last_line}")
end
end
# Removes unwanted SQL added by certain versions of pg_dump and modifies
# the dump so that it works on the current version of PostgreSQL.
def sed_command
unwanted_sql = [
"DROP SCHEMA", # Discourse <= v1.5
"CREATE SCHEMA", # PostgreSQL 11+
"COMMENT ON SCHEMA", # PostgreSQL 11+
"SET default_table_access_method", # PostgreSQL 12
].join("|")
command = "sed -E '/^(#{unwanted_sql})/d' #{@db_dump_path}"
if BackupRestore.postgresql_major_version < 11
command = "#{command} | sed -E 's/^(CREATE TRIGGER.+EXECUTE) FUNCTION/\\1 PROCEDURE/'"
end
command
end
def restore_dump_command
"#{sed_command} | #{self.class.psql_command} 2>&1"
end
def self.psql_command
db_conf = BackupRestore.database_configuration
password_argument = "PGPASSWORD='#{db_conf.password}'" if db_conf.password.present?
host_argument = "--host=#{db_conf.host}" if db_conf.host.present?
port_argument = "--port=#{db_conf.port}" if db_conf.port.present?
username_argument = "--username=#{db_conf.username}" if db_conf.username.present?
[
password_argument, # pass the password to psql (if any)
"psql", # the psql command
"--dbname='#{db_conf.database}'", # connect to database *dbname*
"--single-transaction", # all or nothing (also runs COPY commands faster)
"--variable=ON_ERROR_STOP=1", # stop on first error
host_argument, # the hostname to connect to (if any)
port_argument, # the port to connect to (if any)
username_argument, # the username to connect as (if any)
].compact.join(" ")
end
def pause_before_migration
puts ""
puts "Attention! Pausing restore before migrating database.".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
def migrate_database
log "Migrating the database..."
log Discourse::Utils.execute_command(
{
"SKIP_POST_DEPLOYMENT_MIGRATIONS" => "0",
"SKIP_OPTIMIZE_ICONS" => "1",
"DISABLE_TRANSLATION_OVERRIDES" => "1",
},
"rake",
"db:migrate",
failure_message: "Failed to migrate database.",
chdir: Rails.root,
)
end
def reconnect_database
log "Reconnecting to the database..."
RailsMultisite::ConnectionManagement.reload if RailsMultisite::ConnectionManagement.instance
RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
end
def create_missing_discourse_functions
log "Creating missing functions in the discourse_functions schema..."
@created_functions_for_table_columns = []
all_readonly_table_columns = []
DatabaseRestorer.all_migration_files.each do |path|
file_content = File.read(path)
next if file_content.exclude?("DROPPED_TABLES") && file_content.exclude?("DROPPED_COLUMNS")
require path
class_name = File.basename(path, ".rb").sub(/\A\d+_/, "").camelize
migration_class = class_name.constantize
if migration_class.const_defined?(:DROPPED_TABLES)
migration_class::DROPPED_TABLES.each do |table_name|
all_readonly_table_columns << [table_name]
end
end
if migration_class.const_defined?(:DROPPED_COLUMNS)
migration_class::DROPPED_COLUMNS.each do |table_name, column_names|
column_names.each do |column_name|
all_readonly_table_columns << [table_name, column_name]
end
end
end
end
existing_function_names =
Migration::BaseDropper.existing_discourse_function_names.map { |name| "#{name}()" }
all_readonly_table_columns.each do |table_name, column_name|
function_name =
Migration::BaseDropper.readonly_function_name(table_name, column_name, with_schema: false)
if !existing_function_names.include?(function_name)
Migration::BaseDropper.create_readonly_function(table_name, column_name)
@created_functions_for_table_columns << [table_name, column_name]
end
end
end
def drop_created_discourse_functions
return if @created_functions_for_table_columns.blank?
log "Dropping functions from the discourse_functions schema..."
@created_functions_for_table_columns.each do |table_name, column_name|
Migration::BaseDropper.drop_readonly_function(table_name, column_name)
end
rescue => ex
log "Something went wrong while dropping functions from the discourse_functions schema", ex
end
def self.backup_schema_dropable?
return false unless ActiveRecord::Base.connection.schema_exists?(BACKUP_SCHEMA)
if last_restore_date = BackupMetadata.last_restore_date
return last_restore_date + DROP_BACKUP_SCHEMA_AFTER_DAYS.days < Time.zone.now
end
BackupMetadata.update_last_restore_date
false
end
private_class_method :backup_schema_dropable?
end
end