discourse/lib/backup_restore/backuper.rb
2024-05-27 18:06:14 +02:00

470 lines
14 KiB
Ruby

# frozen_string_literal: true
require "mini_mime"
require "file_store/s3_store"
module BackupRestore
class Backuper
attr_reader :success, :store
def initialize(user_id, opts = {})
@user_id = user_id
@client_id = opts[:client_id]
@publish_to_message_bus = opts[:publish_to_message_bus] || false
@with_uploads = opts[:with_uploads] == false ? false : include_uploads?
@filename_override = opts[:filename]
@ticket = opts[:ticket]
initialize_state
end
def run
ensure_no_operation_is_running
ensure_we_have_a_user
log "[STARTED]"
log "'#{@user.username}' has started the backup!"
mark_backup_as_running
listen_for_shutdown_signal
ensure_directory_exists(@tmp_directory)
ensure_directory_exists(@archive_directory)
update_metadata
dump_public_schema
log "Finalizing backup..."
@with_uploads ? create_archive : move_dump_backup
upload_archive
after_create_hook
rescue SystemExit
log "Backup process was cancelled!"
rescue Exception => ex
log "EXCEPTION: " + ex.message
log ex.backtrace.join("\n")
else
@success = true
@backup_filename
ensure
delete_old
clean_up
notify_user
log "Finished!"
publish_completion
end
def delete_prior_to_n_days
return if Rails.env.development?
store.delete_prior_to_n_days
rescue => ex
log "Something went wrong while deleting backups prior to n days....", ex
end
protected
def ensure_no_operation_is_running
raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running?
end
def ensure_we_have_a_user
raise Discourse::InvalidParameters.new(:user_id) unless @user
end
def get_parameterized_title
SiteSetting.title.parameterize.presence || "discourse"
end
def initialize_state
@success = false
@user = User.find_by(id: @user_id)
@logs = []
@store = BackupRestore::BackupStore.create
@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)
@archive_directory = BackupRestore::LocalBackupStore.base_directory(db: @current_db)
filename = @filename_override || "#{get_parameterized_title}-#{@timestamp}"
@archive_basename =
File.join(
@archive_directory,
"#{filename}-#{BackupRestore::VERSION_PREFIX}#{BackupRestore.current_version}",
)
@backup_filename =
if @with_uploads
"#{File.basename(@archive_basename)}.tar.gz"
else
"#{File.basename(@archive_basename)}.sql.gz"
end
end
def listen_for_shutdown_signal
BackupRestore.clear_shutdown_signal!
Thread.new do
Thread.current.name = "shutdown_wait"
RailsMultisite::ConnectionManagement.with_connection(@current_db) do
while BackupRestore.is_operation_running?
exit if BackupRestore.should_shutdown?
sleep 0.1
end
end
end
end
def mark_backup_as_running
log "Marking backup as running..."
BackupRestore.mark_as_running!
end
def update_metadata
log "Updating metadata..."
BackupMetadata.delete_all
BackupMetadata.create!(name: "base_url", value: Discourse.base_url)
BackupMetadata.create!(name: "cdn_url", value: Discourse.asset_host)
BackupMetadata.create!(
name: "s3_base_url",
value: SiteSetting.Upload.enable_s3_uploads ? SiteSetting.Upload.s3_base_url : nil,
)
BackupMetadata.create!(
name: "s3_cdn_url",
value: SiteSetting.Upload.enable_s3_uploads ? SiteSetting.Upload.s3_cdn_url : nil,
)
BackupMetadata.create!(
name: "db_name",
value: RailsMultisite::ConnectionManagement.current_db,
)
BackupMetadata.create!(name: "multisite", value: Rails.configuration.multisite)
end
def dump_public_schema
log "Dumping the public schema of the database..."
logs = Queue.new
pg_dump_running = true
Thread.new do
RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
while pg_dump_running
message = logs.pop.strip
log(message) if message.present?
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 pg_dump_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 pg_dump (if any)
"pg_dump", # the pg_dump command
"--schema=public", # only public schema
"-T public.pg_*", # exclude tables and views whose name starts with "pg_"
"--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
"--compress=4", # Compression level of 4
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)
db_conf.database, # the name of the database to dump
].join(" ")
end
def move_dump_backup
log "Finalizing database dump file: #{@backup_filename}"
archive_filename = File.join(@archive_directory, @backup_filename)
Discourse::Utils.execute_command(
"mv",
@dump_filename,
archive_filename,
failure_message: "Failed to move database dump file.",
)
remove_tmp_directory
end
def create_archive
log "Creating archive: #{@backup_filename}"
tar_filename = "#{@archive_basename}.tar"
log "Making sure archive does not already exist..."
Discourse::Utils.execute_command("rm", "-f", tar_filename)
Discourse::Utils.execute_command("rm", "-f", "#{tar_filename}.gz")
log "Creating empty archive..."
Discourse::Utils.execute_command(
"tar",
"--create",
"--file",
tar_filename,
"--files-from",
"/dev/null",
)
log "Archiving data dump..."
Discourse::Utils.execute_command(
"tar",
"--append",
"--dereference",
"--file",
tar_filename,
File.basename(@dump_filename),
failure_message: "Failed to archive data dump.",
chdir: File.dirname(@dump_filename),
)
add_local_uploads_to_archive(tar_filename)
add_remote_uploads_to_archive(tar_filename) if SiteSetting.Upload.enable_s3_uploads
remove_tmp_directory
log "Gzipping archive, this may take a while..."
Discourse::Utils.execute_command(
"gzip",
"-#{SiteSetting.backup_gzip_compression_level_for_uploads}",
tar_filename,
failure_message: "Failed to gzip archive.",
)
end
def include_uploads?
has_local_uploads? || SiteSetting.include_s3_uploads_in_backups
end
def local_uploads_directory
@local_uploads_directory ||= File.join(Rails.root, "public", Discourse.store.upload_path)
end
def has_local_uploads?
File.directory?(local_uploads_directory) && !Dir.empty?(local_uploads_directory)
end
def add_local_uploads_to_archive(tar_filename)
log "Archiving uploads..."
if has_local_uploads?
upload_directory = Discourse.store.upload_path
if SiteSetting.include_thumbnails_in_backups
exclude_optimized = ""
else
optimized_path = File.join(upload_directory, "optimized")
exclude_optimized = "--exclude=#{optimized_path}"
end
Discourse::Utils.execute_command(
"tar",
"--append",
"--dereference",
exclude_optimized,
"--file",
tar_filename,
upload_directory,
failure_message: "Failed to archive uploads.",
success_status_codes: [0, 1],
chdir: File.join(Rails.root, "public"),
)
else
log "No local uploads found. Skipping archiving of local uploads..."
end
end
def add_remote_uploads_to_archive(tar_filename)
if !SiteSetting.include_s3_uploads_in_backups
log "Skipping uploads stored on S3."
return
end
log "Downloading uploads from S3. This may take a while..."
store = FileStore::S3Store.new
upload_directory = Discourse.store.upload_path
count = 0
Upload.find_each do |upload|
next if upload.local?
filename = File.join(@tmp_directory, upload_directory, store.get_path_for_upload(upload))
begin
FileUtils.mkdir_p(File.dirname(filename))
store.download_file(upload, filename)
rescue StandardError => ex
log "Failed to download file with upload ID #{upload.id} from S3", ex
end
count += 1
log "#{count} files have already been downloaded. Still downloading..." if count % 500 == 0
end
log "Appending uploads to archive..."
Discourse::Utils.execute_command(
"tar",
"--append",
"--file",
tar_filename,
upload_directory,
failure_message: "Failed to append uploads to archive.",
success_status_codes: [0, 1],
chdir: @tmp_directory,
)
log "No uploads found on S3. Skipping archiving of uploads stored on S3..." if count == 0
end
def upload_archive
return unless store.remote?
log "Uploading archive..."
content_type = MiniMime.lookup_by_filename(@backup_filename).content_type
archive_path = File.join(@archive_directory, @backup_filename)
store.upload_file(@backup_filename, archive_path, content_type)
end
def after_create_hook
log "Executing the after_create_hook for the backup..."
DiscourseEvent.trigger(:backup_created)
end
def delete_old
return if Rails.env.development?
log "Deleting old backups..."
store.delete_old
rescue => ex
log "Something went wrong while deleting old backups.", ex
end
def notify_user
return if success && @user.id == Discourse::SYSTEM_USER_ID
log "Notifying '#{@user.username}' of the end of the backup..."
status = success ? :backup_succeeded : :backup_failed
logs = Discourse::Utils.logs_markdown(@logs, user: @user)
post = SystemMessage.create_from_system_user(@user, status, logs: logs)
post.topic.invite_group(@user, Group[:admins]) if @user.id == Discourse::SYSTEM_USER_ID
rescue => ex
log "Something went wrong while notifying user.", ex
end
def clean_up
log "Cleaning stuff up..."
delete_uploaded_archive
remove_tar_leftovers
mark_backup_as_not_running
refresh_disk_space if success
end
def delete_uploaded_archive
return unless store.remote?
archive_path = File.join(@archive_directory, @backup_filename)
if File.exist?(archive_path)
log "Removing archive from local storage..."
File.delete(archive_path)
end
rescue => ex
log "Something went wrong while deleting uploaded archive from local storage.", ex
end
def refresh_disk_space
log "Refreshing disk stats..."
store.reset_cache
rescue => ex
log "Something went wrong while refreshing disk stats.", ex
end
def remove_tar_leftovers
log "Removing '.tar' leftovers..."
Dir["#{@archive_directory}/*.tar"].each { |filename| File.delete(filename) }
rescue => ex
log "Something went wrong while removing '.tar' leftovers.", ex
end
def remove_tmp_directory
log "Removing tmp '#{@tmp_directory}' directory..."
FileUtils.rm_rf(@tmp_directory) if Dir[@tmp_directory].present?
rescue => ex
log "Something went wrong while removing the following tmp directory: #{@tmp_directory}", ex
end
def mark_backup_as_not_running
log "Marking backup as finished..."
BackupRestore.mark_as_not_running!
rescue => ex
log "Something went wrong while marking backup as finished.", ex
end
def ensure_directory_exists(directory)
log "Making sure '#{directory}' exists..."
FileUtils.mkdir_p(directory)
end
def log(message, ex = nil)
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
puts(message) if !Rails.env.test?
publish_log(message, timestamp)
save_log(message, timestamp)
Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n")) if ex
end
def publish_log(message, timestamp)
return unless @publish_to_message_bus
data = { timestamp: timestamp, operation: "backup", message: message }
MessageBus.publish(
BackupRestore::LOGS_CHANNEL,
data,
user_ids: [@user_id],
client_ids: [@client_id],
)
end
def save_log(message, timestamp)
@logs << "[#{timestamp}] #{message}"
end
def publish_completion
if success
log("[SUCCESS]")
DiscourseEvent.trigger(:backup_complete, logs: @logs, ticket: @ticket)
else
log("[FAILED]")
DiscourseEvent.trigger(:backup_failed, logs: @logs, ticket: @ticket)
end
end
end
end