app/services/backup_uploader.rb
Rama develop · réplica de lectura del repositorio original.
# Stores a tar archive uploaded by the user as a regular HELIOS backup.
# The file is validated to look like a HELIOS backup (PostgreSQL dump,
# InfluxDB dump, helios/config.yaml) before it is moved into the backups
# directory.
class BackupUploader
class Error < StandardError; end
MAX_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB
class << self
delegate :start, to: :new
end
def start(uploaded_file)
@uploaded_file = uploaded_file
validate_runtime!
validate_upload!
target_filename = pick_filename
target_path = ::File.join(BackupRepository.directory, target_filename)
raise Error, error(:already_exists) if ::File.exist?(target_path)
BackupRepository.clear_error!
persist!(target_path)
BackupRepository.record_backup!(target_filename)
BackupRepository.prune!
end
private
attr_reader :uploaded_file
def error(key, **)
I18n.t("backups.uploader.errors.#{key}", **)
end
def validate_runtime!
# Upload writes directly into HELIOS's own backups dir; for a non-local
# destination the tar would land in the wrong place and never show up in
# the listing. The workaround (place the file at the destination
# manually, or switch the destination back to local) is documented in
# the message.
raise Error, error(:remote_destination) if BackupRepository.remote?
raise Error, error(:backup_in_progress) if BackupRunner.in_progress
raise Error, error(:restore_in_progress) if RestoreRunner.in_progress
end
def validate_upload!
validate_metadata!
validate_archive!
end
def validate_metadata!
raise Error, error(:missing) if uploaded_file.blank?
raise Error, error(:not_tar) unless uploaded_file.original_filename.to_s.downcase.end_with?('.tar')
raise Error, error(:too_large) if uploaded_file.size.to_i > MAX_BYTES
raise Error, error(:invalid_archive) unless ustar_archive?
end
def validate_archive!
@archive = BackupRepository.read_archive(uploaded_file.tempfile.path)
raise Error, error(:invalid_archive) if @archive.entries.empty?
raise Error, error(:missing_postgres) unless entry_matches?(BackupRepository::POSTGRESQL_ENTRY_PATTERN)
raise Error, error(:missing_influx) unless entry_matches?(BackupRepository::INFLUXDB_ENTRY_PATTERN)
raise Error, error(:missing_config) if @archive.config.blank?
end
# Posix tar headers carry "ustar" at offset 257. We reject anything without
# it — an older v7 tar would slip through, but `Gem::Package::TarWriter`
# (which produced every legitimate HELIOS backup) always writes the ustar
# magic, so anything else is much more likely to be junk than a real backup.
def ustar_archive?
::File.open(uploaded_file.tempfile.path, 'rb') do |io|
io.seek(257)
io.read(5) == 'ustar'
end
rescue Errno::ENOENT
false
end
def entry_matches?(pattern)
@archive.entries.any? { |entry| entry.name.match?(pattern) }
end
def pick_filename
candidate = uploaded_file.original_filename.to_s
return candidate if BackupRepository.valid_filename?(candidate)
"solectrus-backup-#{Time.current.strftime('%Y%m%d-%H%M%S')}.tar"
end
def persist!(target_path)
FileUtils.mkdir_p(BackupRepository.directory)
part_path = "#{target_path}.part"
FileUtils.mv(uploaded_file.tempfile.path, part_path)
::File.rename(part_path, target_path)
apply_mtime_from_filename!(target_path)
rescue SystemCallError => e
FileUtils.rm_f(part_path) if part_path
raise Error, error(:write_failed, message: e.message)
end
# Align the file's mtime with the timestamp baked into the filename, so
# `ls -l` (and any tooling outside HELIOS) shows when the backup was taken
# rather than when it was uploaded.
def apply_mtime_from_filename!(target_path)
timestamp = BackupRepository.created_at_from(::File.basename(target_path))
return unless timestamp
::File.utime(timestamp.to_i, timestamp.to_i, target_path)
end
end