2020-01-12 18:12:27 -05:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
module BackupRestore
|
|
|
|
DatabaseRestoreError = Class.new(RuntimeError)
|
|
|
|
|
|
|
|
class DatabaseRestorer
|
|
|
|
delegate :log, to: :@logger, private: true
|
|
|
|
|
|
|
|
MAIN_SCHEMA = "public"
|
|
|
|
BACKUP_SCHEMA = "backup"
|
2020-01-15 17:19:13 -05:00
|
|
|
DROP_BACKUP_SCHEMA_AFTER_DAYS = 7
|
2020-01-12 18:12:27 -05:00
|
|
|
|
|
|
|
def initialize(logger, current_db)
|
|
|
|
@logger = logger
|
|
|
|
@db_was_changed = false
|
|
|
|
@current_db = current_db
|
|
|
|
end
|
|
|
|
|
|
|
|
def restore(db_dump_path)
|
|
|
|
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
|
|
|
|
migrate_database
|
|
|
|
reconnect_database
|
2020-01-15 17:19:13 -05:00
|
|
|
|
|
|
|
self.class.update_last_restore_date
|
2020-01-12 18:12:27 -05:00
|
|
|
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
|
|
|
|
|
2020-01-15 17:19:13 -05:00
|
|
|
def self.drop_backup_schema
|
|
|
|
if backup_schema_dropable?
|
|
|
|
ActiveRecord::Base.connection.drop_schema(BACKUP_SCHEMA)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.update_last_restore_date
|
|
|
|
BackupMetadata.where(name: BackupMetadata::LAST_RESTORE_DATE).delete_all
|
|
|
|
BackupMetadata.create!(
|
|
|
|
name: BackupMetadata::LAST_RESTORE_DATE,
|
|
|
|
value: Time.zone.now.iso8601
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2020-01-12 18:12:27 -05:00
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
|
|
raise DatabaseRestoreError.new("psql failed: #{last_line}") if Process.last_status&.exitstatus != 0
|
|
|
|
end
|
|
|
|
|
|
|
|
# Removes unwanted SQL added by certain versions of pg_dump.
|
|
|
|
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("|")
|
|
|
|
|
|
|
|
"sed -E '/^(#{unwanted_sql})/d'"
|
|
|
|
end
|
|
|
|
|
|
|
|
def restore_dump_command
|
2020-01-15 17:37:21 -05:00
|
|
|
"#{sed_command} #{@db_dump_path} | #{self.class.psql_command} 2>&1"
|
2020-01-12 18:12:27 -05:00
|
|
|
end
|
|
|
|
|
2020-01-15 17:37:21 -05:00
|
|
|
def self.psql_command
|
2020-01-12 18:12:27 -05:00
|
|
|
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 migrate_database
|
|
|
|
log "Migrating the database..."
|
|
|
|
|
|
|
|
log Discourse::Utils.execute_command(
|
2020-03-12 13:43:27 -04:00
|
|
|
{
|
|
|
|
"SKIP_POST_DEPLOYMENT_MIGRATIONS" => "0",
|
|
|
|
"SKIP_OPTIMIZE_ICONS" => "1",
|
|
|
|
"DISABLE_TRANSLATION_OVERRIDES" => "1"
|
|
|
|
},
|
2020-01-12 18:12:27 -05:00
|
|
|
"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 = []
|
|
|
|
|
|
|
|
Dir[Rails.root.join(Migration::SafeMigrate.post_migration_path, "**/*.rb")].each do |path|
|
|
|
|
require path
|
|
|
|
class_name = File.basename(path, ".rb").sub(/^\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
|
2020-01-15 17:19:13 -05:00
|
|
|
|
|
|
|
def self.backup_schema_dropable?
|
|
|
|
return false unless ActiveRecord::Base.connection.schema_exists?(BACKUP_SCHEMA)
|
|
|
|
|
|
|
|
last_restore_date = BackupMetadata.value_for(BackupMetadata::LAST_RESTORE_DATE)
|
|
|
|
|
|
|
|
if last_restore_date.present?
|
|
|
|
last_restore_date = Time.zone.parse(last_restore_date)
|
|
|
|
return last_restore_date + DROP_BACKUP_SCHEMA_AFTER_DAYS.days < Time.zone.now
|
|
|
|
end
|
|
|
|
|
|
|
|
update_last_restore_date
|
|
|
|
false
|
|
|
|
end
|
|
|
|
private_class_method :backup_schema_dropable?
|
2020-01-12 18:12:27 -05:00
|
|
|
end
|
|
|
|
end
|