2019-05-02 18:17:27 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2014-02-12 23:32:58 -05:00
|
|
|
module BackupRestore
|
|
|
|
|
|
|
|
class OperationRunningError < RuntimeError; end
|
|
|
|
|
2020-01-12 18:12:27 -05:00
|
|
|
VERSION_PREFIX = "v"
|
|
|
|
DUMP_FILE = "dump.sql.gz"
|
2014-02-13 13:41:46 -05:00
|
|
|
LOGS_CHANNEL = "/admin/backups/logs"
|
2014-02-12 23:32:58 -05:00
|
|
|
|
2014-08-20 12:48:56 -04:00
|
|
|
def self.backup!(user_id, opts = {})
|
2017-12-20 17:00:23 -05:00
|
|
|
if opts[:fork] == false
|
|
|
|
BackupRestore::Backuper.new(user_id, opts).run
|
|
|
|
else
|
2020-09-18 11:29:43 -04:00
|
|
|
spawn_process!(:backup, user_id, opts)
|
2017-12-20 17:00:23 -05:00
|
|
|
end
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
2015-08-27 14:02:13 -04:00
|
|
|
def self.restore!(user_id, opts = {})
|
2020-09-18 11:29:43 -04:00
|
|
|
spawn_process!(:restore, user_id, opts)
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.rollback!
|
|
|
|
raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running?
|
|
|
|
if can_rollback?
|
2014-02-19 09:25:31 -05:00
|
|
|
move_tables_between_schemas("backup", "public")
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.cancel!
|
|
|
|
set_shutdown_signal!
|
|
|
|
true
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.mark_as_running!
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.setex(running_key, 60, "1")
|
2014-02-13 13:41:46 -05:00
|
|
|
save_start_logs_message_id
|
2014-05-13 10:18:08 -04:00
|
|
|
keep_it_running
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.is_operation_running?
|
2019-12-03 04:05:53 -05:00
|
|
|
!!Discourse.redis.get(running_key)
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.mark_as_not_running!
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.del(running_key)
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.should_shutdown?
|
2019-12-03 04:05:53 -05:00
|
|
|
!!Discourse.redis.get(shutdown_signal_key)
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.can_rollback?
|
|
|
|
backup_tables_count > 0
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.operations_status
|
|
|
|
{
|
|
|
|
is_operation_running: is_operation_running?,
|
|
|
|
can_rollback: can_rollback?,
|
2014-08-28 17:02:26 -04:00
|
|
|
allow_restore: Rails.env.development? || SiteSetting.allow_restore
|
2014-02-12 23:32:58 -05:00
|
|
|
}
|
|
|
|
end
|
|
|
|
|
2014-02-13 13:41:46 -05:00
|
|
|
def self.logs
|
|
|
|
id = start_logs_message_id
|
2015-05-03 22:21:00 -04:00
|
|
|
MessageBus.backlog(LOGS_CHANNEL, id).map { |m| m.data }
|
2014-02-13 13:41:46 -05:00
|
|
|
end
|
|
|
|
|
2014-02-12 23:32:58 -05:00
|
|
|
def self.current_version
|
|
|
|
ActiveRecord::Migrator.current_version
|
|
|
|
end
|
|
|
|
|
2020-06-16 09:59:35 -04:00
|
|
|
def self.postgresql_major_version
|
|
|
|
DB.query_single("SHOW server_version").first[/\d+/].to_i
|
|
|
|
end
|
|
|
|
|
2014-02-19 09:25:31 -05:00
|
|
|
def self.move_tables_between_schemas(source, destination)
|
2020-03-31 09:07:52 -04:00
|
|
|
owner = database_configuration.username
|
|
|
|
|
2020-01-12 18:12:27 -05:00
|
|
|
ActiveRecord::Base.transaction do
|
2020-03-31 09:07:52 -04:00
|
|
|
DB.exec(move_tables_between_schemas_sql(source, destination, owner))
|
2020-01-12 18:12:27 -05:00
|
|
|
end
|
2014-02-19 09:25:31 -05:00
|
|
|
end
|
|
|
|
|
2020-03-31 09:07:52 -04:00
|
|
|
def self.move_tables_between_schemas_sql(source, destination, owner)
|
2020-01-12 18:12:27 -05:00
|
|
|
<<~SQL
|
2014-02-19 09:25:31 -05:00
|
|
|
DO $$DECLARE row record;
|
|
|
|
BEGIN
|
2014-05-13 10:18:08 -04:00
|
|
|
-- create <destination> schema if it does not exists already
|
|
|
|
-- NOTE: DROP & CREATE SCHEMA is easier, but we don't want to drop the public schema
|
2020-01-12 18:12:27 -05:00
|
|
|
-- otherwise extensions (like hstore & pg_trgm) won't work anymore...
|
2014-07-11 12:29:24 -04:00
|
|
|
CREATE SCHEMA IF NOT EXISTS #{destination};
|
2014-05-13 10:18:08 -04:00
|
|
|
-- move all <source> tables to <destination> schema
|
2020-03-31 09:07:52 -04:00
|
|
|
FOR row IN SELECT tablename FROM pg_tables WHERE schemaname = '#{source}' AND tableowner = '#{owner}'
|
2014-02-19 09:25:31 -05:00
|
|
|
LOOP
|
2014-02-20 12:42:17 -05:00
|
|
|
EXECUTE 'DROP TABLE IF EXISTS #{destination}.' || quote_ident(row.tablename) || ' CASCADE;';
|
2014-02-19 09:25:31 -05:00
|
|
|
EXECUTE 'ALTER TABLE #{source}.' || quote_ident(row.tablename) || ' SET SCHEMA #{destination};';
|
|
|
|
END LOOP;
|
2014-07-11 12:29:24 -04:00
|
|
|
-- move all <source> views to <destination> schema
|
2020-03-31 09:07:52 -04:00
|
|
|
FOR row IN SELECT viewname FROM pg_views WHERE schemaname = '#{source}' AND viewowner = '#{owner}'
|
2014-07-11 12:29:24 -04:00
|
|
|
LOOP
|
|
|
|
EXECUTE 'DROP VIEW IF EXISTS #{destination}.' || quote_ident(row.viewname) || ' CASCADE;';
|
|
|
|
EXECUTE 'ALTER VIEW #{source}.' || quote_ident(row.viewname) || ' SET SCHEMA #{destination};';
|
|
|
|
END LOOP;
|
2022-05-03 18:40:34 -04:00
|
|
|
-- move all <source> enums to <destination> enums
|
|
|
|
FOR row IN (
|
|
|
|
SELECT typname FROM pg_type t
|
|
|
|
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
|
|
WHERE typcategory = 'E' AND n.nspname = '#{source}' AND pg_catalog.pg_get_userbyid(typowner) = '#{owner}'
|
|
|
|
) LOOP
|
|
|
|
EXECUTE 'DROP TYPE IF EXISTS #{destination}.' || quote_ident(row.typname) || ' CASCADE;';
|
|
|
|
EXECUTE 'ALTER TYPE #{source}.' || quote_ident(row.typname) || ' SET SCHEMA #{destination};';
|
|
|
|
END LOOP;
|
2014-02-19 09:25:31 -05:00
|
|
|
END$$;
|
2014-02-12 23:32:58 -05:00
|
|
|
SQL
|
2014-02-19 09:25:31 -05:00
|
|
|
end
|
|
|
|
|
2014-07-30 11:20:25 -04:00
|
|
|
DatabaseConfiguration = Struct.new(:host, :port, :username, :password, :database)
|
2014-02-12 23:32:58 -05:00
|
|
|
|
2014-02-19 09:25:31 -05:00
|
|
|
def self.database_configuration
|
2021-04-21 05:36:32 -04:00
|
|
|
config = ActiveRecord::Base.connection_pool.db_config.configuration_hash
|
2014-02-20 13:11:43 -05:00
|
|
|
config = config.with_indifferent_access
|
|
|
|
|
2020-01-12 18:12:27 -05:00
|
|
|
# credentials for PostgreSQL in CI environment
|
|
|
|
if Rails.env.test?
|
|
|
|
username = ENV["PGUSER"]
|
|
|
|
password = ENV["PGPASSWORD"]
|
|
|
|
end
|
|
|
|
|
2014-02-20 13:11:43 -05:00
|
|
|
DatabaseConfiguration.new(
|
2018-03-08 21:22:29 -05:00
|
|
|
config["backup_host"] || config["host"],
|
|
|
|
config["backup_port"] || config["port"],
|
2020-01-12 18:12:27 -05:00
|
|
|
config["username"] || username || ENV["USER"] || "postgres",
|
|
|
|
config["password"] || password,
|
2014-02-20 13:11:43 -05:00
|
|
|
config["database"]
|
|
|
|
)
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
def self.running_key
|
|
|
|
"backup_restore_operation_is_running"
|
|
|
|
end
|
|
|
|
|
2014-05-13 10:18:08 -04:00
|
|
|
def self.keep_it_running
|
2022-08-23 19:43:42 -04:00
|
|
|
db = RailsMultisite::ConnectionManagement.current_db
|
|
|
|
|
2014-05-13 10:18:08 -04:00
|
|
|
# extend the expiry by 1 minute every 30 seconds
|
|
|
|
Thread.new do
|
2022-08-23 19:43:42 -04:00
|
|
|
RailsMultisite::ConnectionManagement.with_connection(db) do
|
|
|
|
Thread.current.name = "keep_op_running"
|
|
|
|
|
|
|
|
# this thread will be killed when the fork dies
|
|
|
|
while true
|
|
|
|
Discourse.redis.expire(running_key, 1.minute)
|
|
|
|
sleep 30.seconds
|
|
|
|
end
|
2014-05-13 10:18:08 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2014-02-12 23:32:58 -05:00
|
|
|
def self.shutdown_signal_key
|
|
|
|
"backup_restore_operation_should_shutdown"
|
|
|
|
end
|
|
|
|
|
|
|
|
def self.set_shutdown_signal!
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.set(shutdown_signal_key, "1")
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.clear_shutdown_signal!
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.del(shutdown_signal_key)
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
2014-02-13 13:41:46 -05:00
|
|
|
def self.save_start_logs_message_id
|
2015-05-03 22:21:00 -04:00
|
|
|
id = MessageBus.last_id(LOGS_CHANNEL)
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.set(start_logs_message_id_key, id)
|
2014-02-13 13:41:46 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.start_logs_message_id
|
2019-12-03 04:05:53 -05:00
|
|
|
Discourse.redis.get(start_logs_message_id_key).to_i
|
2014-02-13 13:41:46 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.start_logs_message_id_key
|
|
|
|
"start_logs_message_id"
|
|
|
|
end
|
|
|
|
|
2020-09-18 11:29:43 -04:00
|
|
|
def self.spawn_process!(type, user_id, opts)
|
|
|
|
script = File.join(Rails.root, "script", "spawn_backup_restore.rb")
|
2020-10-13 11:40:21 -04:00
|
|
|
command = ["bundle", "exec", "ruby", script, type, user_id, opts.to_json].map(&:to_s)
|
2014-02-12 23:32:58 -05:00
|
|
|
|
2020-10-13 11:40:21 -04:00
|
|
|
pid = spawn({ "RAILS_DB" => RailsMultisite::ConnectionManagement.current_db }, *command)
|
2020-09-18 11:29:43 -04:00
|
|
|
Process.detach(pid)
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def self.backup_tables_count
|
2018-06-19 02:13:14 -04:00
|
|
|
DB.query_single("SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = 'backup'").first.to_i
|
2014-02-12 23:32:58 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
end
|