OpenProject is the leading open source project management software.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
openproject/app/workers/backup_job.rb

273 lines
6.9 KiB

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'tempfile'
require 'zip'
class BackupJob < ::ApplicationJob
queue_with_priority :above_normal
attr_reader :backup, :user
def perform(
backup:,
user:,
include_attachments: Backup.include_attachments?,
attachment_size_max_sum_mb: Backup.attachment_size_max_sum_mb
)
@backup = backup
@user = user
@include_attachments = include_attachments
@attachment_size_max_sum_mb = attachment_size_max_sum_mb
run_backup!
rescue StandardError => e
failure! error: e.message
raise e
ensure
after_backup
end
def run_backup!
@dumped = dump_database! db_dump_file_name # sets error on failure
return unless dumped?
file_name = create_backup_archive!(
file_name: archive_file_name,
db_dump_file_name:
)
store_backup file_name, backup: backup, user: user
cleanup_previous_backups!
notify_backup_ready!
end
def after_backup
remove_files! db_dump_file_name, archive_file_name
remove_backup_attachment! unless success?
Rails.logger.info(
"BackupJob(include_attachments: #{include_attachments?}) finished " \
"with status #{status} " \
"(dumped: #{dumped?}, archived: #{archived?})"
)
end
def notify_backup_ready!
UserMailer.backup_ready(user).deliver_later
end
def dumped?
@dumped
end
def archived?
@archived
end
delegate :status, to: :job_status
def db_dump_file_name
@db_dump_file_name ||= tmp_file_name "openproject", ".sql"
end
def archive_file_name
@archive_file_name ||= tmp_file_name "openproject-backup", ".zip"
end
def status_reference
arguments.first[:backup]
end
def updates_own_status?
true
end
def cleanup_previous_backups!
Backup.where.not(id: backup.id).destroy_all
end
def success?
job_status.status == JobStatus::Status.statuses[:success]
end
def remove_files!(*files)
Array(files).each do |file|
FileUtils.rm_rf file
end
end
def remove_backup_attachment!
backup.attachments.each(&:destroy)
end
def store_backup(file_name, backup:, user:)
File.open(file_name) do |file|
call = Attachments::CreateService
.bypass_whitelist(user:)
.call(container: backup, filename: file_name, file:, description: 'OpenProject backup')
call.on_success do
download_url = ::API::V3::Utilities::PathHelper::ApiV3Path.attachment_content(call.result.id)
upsert_status(
status: :success,
message: I18n.t('export.succeeded'),
payload: download_payload(download_url)
)
end
call.on_failure do
upsert_status status: :failure,
message: I18n.t('export.failed', message: call.message)
end
end
end
def create_backup_archive!(file_name:, db_dump_file_name:, attachments: attachments_to_include)
paths_to_clean = []
clean_up = OpenProject::Configuration.remote_storage?
Zip::File.open(file_name, Zip::File::CREATE) do |zipfile|
attachments.each do |attachment|
# If an attachment is destroyed on disk, skip i
diskfile = attachment.diskfile
next unless diskfile
path = diskfile.path
zipfile.add "attachment/file/#{attachment.id}/#{attachment[:file]}", path
paths_to_clean << get_cache_folder_path(attachment) if clean_up && attachment.file.cached?
end
zipfile.get_output_stream("openproject.sql") { |f| f.write File.read(db_dump_file_name) }
end
remove_paths! paths_to_clean # delete locally cached files that were downloaded just for the backup
@archived = true
file_name
end
def remove_paths!(paths)
paths.each do |path|
FileUtils.rm_rf path
end
end
def get_cache_folder_path(attachment)
# expecting paths like /tmp/op_uploaded_files/1639754082-3468-0002-0911/file.ext
# just making extra sure so we don't delete anything wrong later on
unless attachment.diskfile.path =~ /#{attachment.file.cache_dir}\/[^\/]+\/[^\/]+/
raise "Unexpected cache path for attachment ##{attachment.id}: #{attachment.diskfile}"
end
# returning parent as each cached file is in a separate folder which shall be removed too
Pathname(attachment.diskfile.path).parent.to_s
end
def attachments_to_include
return Attachment.none if skip_attachments?
Backup.attachments_query
end
def skip_attachments?
!(include_attachments? && Backup.attachments_size_in_bounds?(max: attachment_size_max_sum_mb))
end
def date_tag
Time.zone.today.iso8601
end
def tmp_file_name(name, ext)
file = Tempfile.new [name, ext]
file.path
ensure
file.close
file.unlink
end
def include_attachments?
@include_attachments
end
def attachment_size_max_sum_mb
@attachment_size_max_sum_mb
end
def dump_database!(path)
_out, err, st = Open3.capture3 pg_env, dump_command(path)
failure! error: err unless st.success?
st.success?
end
def dump_command(output_file_path)
"pg_dump -x -O -f '#{output_file_path}'"
end
def failure!(error: nil)
msg = I18n.t 'backup.failed'
upsert_status(
status: :failure,
message: error.present? ? "#{msg}: #{error}" : msg
)
end
def pg_env
config = ActiveRecord::Base.connection_db_config.configuration_hash
entries = pg_env_to_connection_config.map do |key, config_key|
value = config[config_key].to_s
[key.to_s, value] if value.present?
end
entries.compact.to_h
end
##
# Maps the PG env variable name to the key in the AR connection config.
def pg_env_to_connection_config
{
PGHOST: :host,
PGPORT: :port,
PGUSER: :username,
PGPASSWORD: :password,
PGDATABASE: :database
}
end
end