From 4aa8f00e1f07b3e0c24dca9c95b43166af3517fe Mon Sep 17 00:00:00 2001 From: Aakriti Gupta <agupta@gitlab.com> Date: Thu, 31 Oct 2024 01:56:51 +0000 Subject: [PATCH] Migrate and refactor files and repo targets to unified backups --- .../lib/gitlab/backup/cli.rb | 1 + .../lib/gitlab/backup/cli/backup_executor.rb | 15 +- .../backup/cli/context/source_context.rb | 14 +- .../lib/gitlab/backup/cli/errors.rb | 2 + .../backup/cli/errors/file_restore_error.rb | 22 ++ .../backup/cli/errors/gitaly_backup_error.rb | 22 ++ .../lib/gitlab/backup/cli/repo_type.rb | 14 ++ .../lib/gitlab/backup/cli/restore_executor.rb | 23 +- .../lib/gitlab/backup/cli/targets.rb | 4 + .../lib/gitlab/backup/cli/targets/database.rb | 14 +- .../lib/gitlab/backup/cli/targets/files.rb | 103 +++++++++ .../backup/cli/targets/gitaly_backup.rb | 187 ++++++++++++++++ .../backup/cli/targets/gitaly_client.rb | 42 ++++ .../cli/targets/object_storage/google.rb | 10 +- .../gitlab/backup/cli/targets/repositories.rb | 96 +++++++++ .../lib/gitlab/backup/cli/targets/target.rb | 15 +- .../lib/gitlab/backup/cli/tasks/artifacts.rb | 2 +- .../lib/gitlab/backup/cli/tasks/builds.rb | 2 +- .../backup/cli/tasks/ci_secure_files.rb | 2 +- .../lib/gitlab/backup/cli/tasks/database.rb | 2 +- .../lib/gitlab/backup/cli/tasks/lfs.rb | 2 +- .../lib/gitlab/backup/cli/tasks/packages.rb | 2 +- .../lib/gitlab/backup/cli/tasks/pages.rb | 2 +- .../lib/gitlab/backup/cli/tasks/registry.rb | 14 +- .../gitlab/backup/cli/tasks/repositories.rb | 19 +- .../lib/gitlab/backup/cli/tasks/task.rb | 27 +-- .../backup/cli/tasks/terraform_state.rb | 2 +- .../lib/gitlab/backup/cli/tasks/uploads.rb | 2 +- .../lib/gitlab/backup/cli/utils/tar.rb | 13 ++ .../spec/fixtures/{ => config}/gitlab.yml | 0 .../gitlab/backup/cli/gitlab_config_spec.rb | 2 +- .../gitlab/backup/cli/targets/files_spec.rb | 188 +++++++++++++++++ .../backup/cli/targets/gitaly_backup_spec.rb | 199 ++++++++++++++++++ .../cli/targets/object_storage/google_spec.rb | 16 +- .../backup/cli/targets/repositories_spec.rb | 74 +++++++ .../spec/gitlab/backup/cli/tasks/task_spec.rb | 11 +- .../spec/gitlab/backup/cli/utils/tar_spec.rb | 53 ++++- gems/gitlab-backup-cli/spec/spec_helper.rb | 1 + .../context_shared_examples.rb | 22 +- shared/artifacts/tmp/cache/.gitkeep | 0 shared/artifacts/tmp/uploads/.gitkeep | 0 41 files changed, 1122 insertions(+), 119 deletions(-) create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/file_restore_error.rb create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/gitaly_backup_error.rb create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/repo_type.rb create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/files.rb create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_backup.rb create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_client.rb create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/repositories.rb rename gems/gitlab-backup-cli/spec/fixtures/{ => config}/gitlab.yml (100%) create mode 100644 gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/files_spec.rb create mode 100644 gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/gitaly_backup_spec.rb create mode 100644 gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/repositories_spec.rb delete mode 100644 shared/artifacts/tmp/cache/.gitkeep delete mode 100644 shared/artifacts/tmp/uploads/.gitkeep diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli.rb index b8fa1e08fc88e..ee6fef06a67d8 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli.rb @@ -20,6 +20,7 @@ module Cli autoload :GitlabConfig, 'gitlab/backup/cli/gitlab_config' autoload :Metadata, 'gitlab/backup/cli/metadata' autoload :Output, 'gitlab/backup/cli/output' + autoload :RepoType, 'gitlab/backup/cli/repo_type' autoload :RestoreExecutor, 'gitlab/backup/cli/restore_executor' autoload :Runner, 'gitlab/backup/cli/runner' autoload :Shell, 'gitlab/backup/cli/shell' diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/backup_executor.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/backup_executor.rb index 8df1a3edc89da..4d27bdfc231ca 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/backup_executor.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/backup_executor.rb @@ -51,20 +51,17 @@ def build_metadata end def execute_all_tasks - # TODO: when we migrate targets to the new codebase, recreate options to have only what we need here - # https://gitlab.com/gitlab-org/gitlab/-/issues/454906 - options = ::Backup::Options.new( - remote_directory: backup_bucket, - container_registry_bucket: registry_bucket, - service_account_file: service_account_file - ) tasks = [] - Gitlab::Backup::Cli::Tasks.build_each(context: context, options: options) do |task| + Gitlab::Backup::Cli::Tasks.build_each(context: context) do |task| + # This is a temporary hack while we move away from options and use config instead + # This hack will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/498455 + task.set_registry_bucket(registry_bucket) if task.is_a?(Gitlab::Backup::Cli::Tasks::Registry) + Gitlab::Backup::Cli::Output.info("Executing Backup of #{task.human_name}...") duration = measure_duration do - task.backup!(workdir, metadata.backup_id) + task.backup!(workdir) tasks << task end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/context/source_context.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/context/source_context.rb index 1feb2efbd80f2..ba3a2caa285cc 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/context/source_context.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/context/source_context.rb @@ -104,7 +104,7 @@ def upload_path end def config(object_type) - Gitlab.config[object_type] + gitlab_config[object_type] end def env @@ -112,6 +112,18 @@ def env ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development") end + def config_repositories_storages + gitlab_config.dig(env, 'repositories', 'storages') + end + + def gitaly_backup_path + gitlab_config.dig(env, 'backup', 'gitaly_backup_path') + end + + def gitaly_token + gitlab_config.dig(env, 'gitaly', 'token') + end + private # Return the shared path used as a fallback base location to each blob type diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors.rb index 68060d7dcb144..6cd73adf3a201 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors.rb @@ -6,6 +6,8 @@ module Cli module Errors autoload :DatabaseBackupError, 'gitlab/backup/cli/errors/database_backup_error' autoload :FileBackupError, 'gitlab/backup/cli/errors/file_backup_error' + autoload :FileRestoreError, 'gitlab/backup/cli/errors/file_restore_error' + autoload :GitalyBackupError, 'gitlab/backup/cli/errors/gitaly_backup_error' end end end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/file_restore_error.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/file_restore_error.rb new file mode 100644 index 0000000000000..451948fe52baf --- /dev/null +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/file_restore_error.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gitlab + module Backup + module Cli + module Errors + class FileRestoreError < StandardError + attr_reader :error_message + + def initialize(error_message:) + super + @error_message = error_message + end + + def message + "Restore operation failed: #{error_message}" + end + end + end + end + end +end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/gitaly_backup_error.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/gitaly_backup_error.rb new file mode 100644 index 0000000000000..318515d934a5d --- /dev/null +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/errors/gitaly_backup_error.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Gitlab + module Backup + module Cli + module Errors + class GitalyBackupError < StandardError + attr_reader :error_message + + def initialize(error_message = '') + super + @error_message = error_message + end + + def message + "Repository Backup/Restore failed. #{error_message}" + end + end + end + end + end +end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/repo_type.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/repo_type.rb new file mode 100644 index 0000000000000..15e9cae753945 --- /dev/null +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/repo_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Gitlab + module Backup + module Cli + class RepoType + PROJECT = :project + WIKI = :wiki + SNIPPET = :snippet + DESIGN = :design + end + end + end +end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/restore_executor.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/restore_executor.rb index 25a76382d5ea0..e7cc30ad34378 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/restore_executor.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/restore_executor.rb @@ -41,10 +41,6 @@ def execute execute_all_tasks end - def backup_options - @backup_options ||= build_backup_options! - end - def metadata @metadata ||= read_metadata! end @@ -57,14 +53,16 @@ def release! private def execute_all_tasks - # TODO: when we migrate targets to the new codebase, recreate options to have only what we need here - # https://gitlab.com/gitlab-org/gitlab/-/issues/454906 tasks = [] - Gitlab::Backup::Cli::Tasks.build_each(context: context, options: backup_options) do |task| + Gitlab::Backup::Cli::Tasks.build_each(context: context) do |task| + # This is a temporary hack while we move away from options and use config instead + # This hack will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/498455 + task.set_registry_bucket(registry_bucket) if task.is_a?(Gitlab::Backup::Cli::Tasks::Registry) + Gitlab::Backup::Cli::Output.info("Executing restoration of #{task.human_name}...") duration = measure_duration do - tasks << { name: task.human_name, result: task.restore!(archive_directory, backup_id) } + tasks << { name: task.human_name, result: task.restore!(archive_directory) } end next if task.object_storage? @@ -87,15 +85,6 @@ def read_metadata! @metadata = Gitlab::Backup::Cli::Metadata::BackupMetadata.load!(archive_directory) end - def build_backup_options! - ::Backup::Options.new( - backup_id: backup_id, - remote_directory: backup_bucket, - container_registry_bucket: registry_bucket, - service_account_file: service_account_file - ) - end - # @return [Pathname] temporary directory def create_temporary_workdir! # Ensure base directory exists diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets.rb index e66a3d78ce553..8a6679276769a 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets.rb @@ -6,7 +6,11 @@ module Cli module Targets autoload :Target, 'gitlab/backup/cli/targets/target' autoload :Database, 'gitlab/backup/cli/targets/database' + autoload :Files, 'gitlab/backup/cli/targets/files' autoload :ObjectStorage, 'gitlab/backup/cli/targets/object_storage' + autoload :GitalyBackup, 'gitlab/backup/cli/targets/gitaly_backup' + autoload :GitalyClient, 'gitlab/backup/cli/targets/gitaly_client' + autoload :Repositories, 'gitlab/backup/cli/targets/repositories' end end end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/database.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/database.rb index 5cf484ad92ab4..ea5dc7757821d 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/database.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/database.rb @@ -17,14 +17,16 @@ class Database < Target ].freeze IGNORED_ERRORS_REGEXP = Regexp.union(IGNORED_ERRORS).freeze - def initialize(options:) - super(options: options) - + def initialize @errors = [] - @force = options.force? + + # This flag will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/494209 + # This option will be reintroduced as part of + # https://gitlab.com/gitlab-org/gitlab/-/issues/498453 + @force = false end - def dump(destination_dir, _) + def dump(destination_dir) FileUtils.mkdir_p(destination_dir) each_database(destination_dir) do |backup_connection| @@ -74,7 +76,7 @@ def dump(destination_dir, _) end end - def restore(destination_dir, _) + def restore(destination_dir) @errors = [] base_models_for_backup.each do |database_name, _| diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/files.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/files.rb new file mode 100644 index 0000000000000..7a6c18b4017b6 --- /dev/null +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/files.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Gitlab + module Backup + module Cli + module Targets + class Files < Target + DEFAULT_EXCLUDE = ['lost+found'].freeze + + attr_reader :excludes + + # @param [String] storage_path + # @param [Array] excludes + def initialize(context, storage_path, excludes: []) + super(context) + + @storage_path = storage_path + @excludes = excludes + end + + def dump(destination) + archive_file = [destination, 'w', 0o600] + tar_command = Utils::Tar.new.pack_from_stdin_cmd( + target_directory: storage_realpath, + target: '.', + excludes: excludes) + + compression_cmd = Utils::Compression.compression_command + + pipeline = Shell::Pipeline.new(tar_command, compression_cmd) + + result = pipeline.run!(output: archive_file) + + return if success?(result) + + raise Errors::FileBackupError.new(storage_realpath, destination) + end + + def restore(source) + # Existing files will be handled in https://gitlab.com/gitlab-org/gitlab/-/issues/499876 + if File.exist?(storage_realpath) + Output.warning "Ignoring existing files at #{storage_realpath} and continuing restore." + end + + archive_file = source.to_s + tar_command = Utils::Tar.new.extract_from_stdin_cmd(target_directory: storage_realpath) + + decompression_cmd = Utils::Compression.decompression_command + + pipeline = Shell::Pipeline.new(decompression_cmd, tar_command) + result = pipeline.run!(input: archive_file) + + return if success?(result) + + raise Errors::FileRestoreError.new(error_message: result.stderr) + end + + private + + def success?(result) + return true if result.success? + + return true if ignore_non_success?( + result.status_list[1].exitstatus, + result.stderr + ) + + false + end + + def noncritical_warning_matcher + /^g?tar: \.: Cannot mkdir: No such file or directory$/ + end + + def ignore_non_success?(exitstatus, output) + # tar can exit with nonzero code: + # 1 - if some files changed (i.e. a CI job is currently writes to log) + # 2 - if it cannot create `.` directory (see issue https://gitlab.com/gitlab-org/gitlab/-/issues/22442) + # http://www.gnu.org/software/tar/manual/html_section/tar_19.html#Synopsis + # so check tar status 1 or stderr output against some non-critical warnings + if exitstatus == 1 + Output.print_info "Ignoring tar exit status 1 'Some files differ': #{output}" + return true + end + + # allow tar to fail with other non-success status if output contain non-critical warning + if noncritical_warning_matcher&.match?(output) + Output.print_info( + "Ignoring non-success exit status #{exitstatus} due to output of non-critical warning(s): #{output}") + return true + end + + false + end + + def storage_realpath + @storage_realpath ||= File.realpath(@storage_path) + end + end + end + end + end +end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_backup.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_backup.rb new file mode 100644 index 0000000000000..c3770e6d78a3e --- /dev/null +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_backup.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +module Gitlab + module Backup + module Cli + module Targets + class GitalyBackup + # Backup and restores repositories using gitaly-backup + # + # gitaly-backup can work in parallel and accepts a list of repositories + # through input pipe using a specific json format for both backup and restore + attr_reader :context + + def initialize(context) + @context = context + end + + def start(type, backup_repos_path, backup_id: nil, remove_all_repositories: nil) + raise Gitlab::Backup::Cli::Errors::GitalyBackupError, 'already started' if started? + + FileUtils.rm_rf(backup_repos_path) if type == :create + + @input_stream, stdout, @thread = Open3.popen2( + build_env, + bin_path, + *gitaly_backup_args(type, backup_repos_path.to_s, backup_id, remove_all_repositories) + ) + + @out_reader = Thread.new do + IO.copy_stream(stdout, $stdout) + end + end + + def finish! + return unless started? + + @input_stream.close + @thread.join + status = @thread.value + + @thread = nil + + return unless status.exitstatus != 0 + + raise Gitlab::Backup::Cli::Errors::GitalyBackupError, + "gitaly-backup exit status #{status.exitstatus}" + end + + def enqueue(container, repo_type) + raise Gitlab::Backup::Cli::Errors::GitalyBackupError, 'not started' unless started? + raise Gitlab::Backup::Cli::Errors::GitalyBackupError, 'no container for repo type' unless container + + storage, relative_path, gl_project_path, always_create = repository_info_for(container, repo_type) + + schedule_backup_job(storage, relative_path, gl_project_path, always_create) + end + + private + + def repository_info_for(container, repo_type) + case repo_type + when RepoType::PROJECT + [container.repository_storage, + container.disk_path || container.full_path, + container.full_path, + true] + when RepoType::WIKI + wiki_repo_info(container) + when RepoType::SNIPPET + [container.repository_storage, + container.disk_path || container.full_path, + container.full_path, + false] + when RepoType::DESIGN + [design_repo_storage(container), + container.project.disk_path, + container.project.full_path, + false] + end + end + + def design_repo_storage(container) + return container.repository.repository_storage if container.repository.respond_to?(:repository_storage) + + container.repository_storage + end + + def wiki_repo_info(container) + wiki = container.respond_to?(:wiki) ? container.wiki : container + [wiki.repository_storage, + wiki.disk_path || wiki.full_path, + wiki.full_path, + false] + end + + def gitaly_backup_args(type, backup_repos_path, backup_id, remove_all_repositories) + command = case type + when :create + 'create' + when :restore + 'restore' + else + raise Gitlab::Backup::Cli::Errors::GitalyBackupError, "unknown backup type: #{type}" + end + + args = [command] + ['-path', backup_repos_path, '-layout', 'manifest'] + + case type + when :create + args += ['-id', backup_id] if backup_id + when :restore + args += ['-remove-all-repositories', remove_all_repositories.join(',')] if remove_all_repositories + args += ['-id', backup_id] if backup_id + end + + args + end + + # Schedule a new backup job through a non-blocking JSON based pipe protocol + # + # @see https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/gitaly-backup.md + def schedule_backup_job(storage, relative_path, gl_project_path, always_create) + json_job = { + storage_name: storage, + relative_path: relative_path, + gl_project_path: gl_project_path, + always_create: always_create + }.to_json + + @input_stream.puts(json_job) + end + + def gitaly_servers + storages = context.config_repositories_storages + unless storages.keys + raise Gitlab::Backup::Cli::Errors::GitalyBackupError, + "No repositories' storages found." + end + + storages.keys.index_with do |storage_name| + GitalyClient.new(storages, context.gitaly_token).connection_data(storage_name) + end + end + + def gitaly_servers_encoded + Base64.strict_encode64(JSON.dump(gitaly_servers)) + end + + # These variables will be moved to a config file via + # https://gitlab.com/gitlab-org/gitlab/-/issues/500437 + def default_cert_dir + ENV.fetch('SSL_CERT_DIR', OpenSSL::X509::DEFAULT_CERT_DIR) + end + + def default_cert_file + ENV.fetch('SSL_CERT_FILE', OpenSSL::X509::DEFAULT_CERT_FILE) + end + + def build_env + { + 'SSL_CERT_FILE' => default_cert_file, + 'SSL_CERT_DIR' => default_cert_dir, + 'GITALY_SERVERS' => gitaly_servers_encoded + }.merge(current_env) + end + + def current_env + ENV + end + + def started? + @thread.present? + end + + def bin_path + unless context.gitaly_backup_path.present? + raise Gitlab::Backup::Cli::Errors::GitalyBackupError, + 'gitaly-backup binary not found and gitaly_backup_path is not configured' + end + + File.absolute_path(context.gitaly_backup_path) + end + end + end + end + end +end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_client.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_client.rb new file mode 100644 index 0000000000000..5385348386ba2 --- /dev/null +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/gitaly_client.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Gitlab + module Backup + module Cli + module Targets + class GitalyClient + attr_reader :storages, :gitaly_token + + def initialize(storages, gitaly_token) + @storages = storages + @gitaly_token = gitaly_token + end + + def connection_data(storage) + raise "storage not found: #{storage.inspect}" if storages[storage].nil? + + { 'address' => address(storage), 'token' => token(storage) } + end + + private + + def address(storage) + address = storages[storage]['gitaly_address'] + raise "storage #{storage.inspect} is missing a gitaly_address" unless address.present? + + unless %w[tcp unix tls dns].include?(URI(address).scheme) + raise "Unsupported Gitaly address: " \ + "#{address.inspect} does not use URL scheme 'tcp' or 'unix' or 'tls' or 'dns'" + end + + address + end + + def token(storage) + storages[storage]['gitaly_token'].presence || gitaly_token + end + end + end + end + end +end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/object_storage/google.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/object_storage/google.rb index 7900106285736..3a823b72df8dc 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/object_storage/google.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/object_storage/google.rb @@ -12,14 +12,15 @@ class Google < Target attr_accessor :object_type, :backup_bucket, :client, :config, :results - def initialize(object_type, options, config) + def initialize(object_type, remote_directory, config) @object_type = object_type - @backup_bucket = options.remote_directory + @backup_bucket = remote_directory @config = config @client = ::Google::Cloud::StorageTransfer.storage_transfer_service end - def dump(_, backup_id) + # @param [String] backup_id unique identifier for the backup + def dump(backup_id) response = find_or_create_job(backup_id, "backup") run_request = { project_id: backup_job_spec(backup_id)[:project_id], @@ -28,7 +29,8 @@ def dump(_, backup_id) @results = client.run_transfer_job run_request end - def restore(_, backup_id) + # @param [String] backup_id unique identifier for the backup + def restore(backup_id) response = find_or_create_job(backup_id, "restore") run_request = { project_id: restore_job_spec(backup_id)[:project_id], diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/repositories.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/repositories.rb new file mode 100644 index 0000000000000..9cb1b430f80bf --- /dev/null +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/repositories.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'yaml' + +module Gitlab + module Backup + module Cli + module Targets + # Backup and restores repositories by querying the database + class Repositories < Target + def dump(destination) + strategy.start(:create, destination) + enqueue_consecutive + + ensure + strategy.finish! + end + + def restore(source) + strategy.start(:restore, + source, + remove_all_repositories: remove_all_repositories) + enqueue_consecutive + + ensure + strategy.finish! + + restore_object_pools + end + + def strategy + @strategy ||= GitalyBackup.new(context) + end + + private + + def remove_all_repositories + context.config_repositories_storages.keys + end + + def enqueue_consecutive + enqueue_consecutive_projects + enqueue_consecutive_snippets + end + + def enqueue_consecutive_projects + project_relation.find_each(batch_size: 1000) do |project| + enqueue_project(project) + end + end + + def enqueue_consecutive_snippets + snippet_relation.find_each(batch_size: 1000) { |snippet| enqueue_snippet(snippet) } + end + + def enqueue_project(project) + strategy.enqueue(project, Gitlab::Backup::Cli::RepoType::PROJECT) + strategy.enqueue(project, Gitlab::Backup::Cli::RepoType::WIKI) + + return unless project.design_management_repository + + strategy.enqueue(project.design_management_repository, Gitlab::Backup::Cli::RepoType::DESIGN) + end + + def enqueue_snippet(snippet) + strategy.enqueue(snippet, Gitlab::Backup::Cli::RepoType::SNIPPET) + end + + def project_relation + Project.includes(:route, :group, :namespace) + end + + def snippet_relation + Snippet.all + end + + def restore_object_pools + PoolRepository.includes(:source_project).find_each do |pool| + Output.info " - Object pool #{pool.disk_path}..." + + unless pool.source_project + Output.info " - Object pool #{pool.disk_path}... [SKIPPED]" + next + end + + pool.state = 'none' + pool.save + + pool.schedule + end + end + end + end + end + end +end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/target.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/target.rb index d775702642384..b272552a4e391 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/target.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/targets/target.rb @@ -6,14 +6,10 @@ module Cli module Targets # Abstract class used to implement a Backup Target class Target - # Backup creation and restore option flags - # - # TODO: Migrate to a unified backup specific Options implementation - # @return [::Backup::Options] - attr_reader :options + attr_reader :context - def initialize(options:) - @options = options + def initialize(context = nil) + @context = context end def asynchronous? @@ -23,13 +19,12 @@ def asynchronous? # dump task backup to `path` # # @param [String] path fully qualified backup task destination - # @param [String] backup_id unique identifier for the backup - def dump(path, backup_id) + def dump(path) raise NotImplementedError end # restore task backup from `path` - def restore(path, backup_id) + def restore(path) raise NotImplementedError end end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/artifacts.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/artifacts.rb index 14ed04f567c5e..9af8ceaaa8844 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/artifacts.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/artifacts.rb @@ -14,7 +14,7 @@ def destination_path = 'artifacts.tar.gz' private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options, excludes: ['tmp']) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path, excludes: ['tmp']) end def storage_path = context.ci_job_artifacts_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/builds.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/builds.rb index b94f384880719..ed5dffe5071f1 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/builds.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/builds.rb @@ -14,7 +14,7 @@ def destination_path = 'builds.tar.gz' private def target - ::Backup::Targets::Files.new(nil, storage_path, options: options) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path) end def storage_path = context.ci_builds_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/ci_secure_files.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/ci_secure_files.rb index f819d3fd3ab24..55a8f84343db9 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/ci_secure_files.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/ci_secure_files.rb @@ -14,7 +14,7 @@ def destination_path = 'ci_secure_files.tar.gz' private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options, excludes: ['tmp']) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path, excludes: ['tmp']) end def storage_path = context.ci_secure_files_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/database.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/database.rb index 1790b109c03f7..5c4e94326c9f0 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/database.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/database.rb @@ -16,7 +16,7 @@ def cleanup_path = 'db' private def target - ::Gitlab::Backup::Cli::Targets::Database.new(options: options) + ::Gitlab::Backup::Cli::Targets::Database.new end end end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/lfs.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/lfs.rb index 46c96d4c8ecc6..50cb98eadbe73 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/lfs.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/lfs.rb @@ -14,7 +14,7 @@ def destination_path = 'lfs.tar.gz' private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path) end def storage_path = context.ci_lfs_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/packages.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/packages.rb index f3bca473d9786..6696a23df42d1 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/packages.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/packages.rb @@ -14,7 +14,7 @@ def destination_path = 'packages.tar.gz' private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options, excludes: ['tmp']) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path, excludes: ['tmp']) end def storage_path = context.packages_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/pages.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/pages.rb index f2c12e1d70999..e5715efb33cb1 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/pages.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/pages.rb @@ -18,7 +18,7 @@ def destination_path = 'pages.tar.gz' private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options, excludes: [LEGACY_PAGES_TMP_PATH]) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path, excludes: [LEGACY_PAGES_TMP_PATH]) end def storage_path = context.pages_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/registry.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/registry.rb index 80f97fbb66add..9d6893a969e04 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/registry.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/registry.rb @@ -13,16 +13,22 @@ def human_name = _('container registry images') def destination_path = 'registry.tar.gz' + attr_reader :registry_bucket + + def set_registry_bucket(registry_bucket) + @registry_bucket = registry_bucket + end + def object_storage? - !options.container_registry_bucket.nil? + !registry_bucket.nil? end # Registry does not use consolidated object storage config. def config settings = { object_store: { - connection: context.config('object_store').connection.to_hash, - remote_directory: options.container_registry_bucket + connection: context.gitlab_config('object_store').connection.to_hash, + remote_directory: registry_bucket } } GitlabSettings::Options.build(settings) @@ -31,7 +37,7 @@ def config private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path) end def storage_path = context.registry_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/repositories.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/repositories.rb index f4d172d623e52..5f20f4c233e55 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/repositories.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/repositories.rb @@ -16,24 +16,7 @@ def destination_optional = true private def target - # TODO: migrate to the new codebase and rewrite portions to format output in a readable way - ::Backup::Targets::Repositories.new($stdout, - strategy: gitaly_strategy, - options: options, - storages: options.repositories_storages, - paths: options.repositories_paths, - skip_paths: options.skip_repositories_paths - ) - end - - def gitaly_strategy - # TODO: migrate to the new codebase and rewrite portions to format output in a readable way - ::Backup::GitalyBackup.new($stdout, - incremental: options.incremental?, - max_parallelism: options.max_parallelism, - storage_parallelism: options.max_storage_parallelism, - server_side: false - ) + Gitlab::Backup::Cli::Targets::Repositories.new(context) end end end diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/task.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/task.rb index 1d1fde51fcda7..81295abea0b7c 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/task.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/task.rb @@ -5,36 +5,34 @@ module Backup module Cli module Tasks class Task - attr_reader :options, :context attr_writer :target + attr_reader :context + + def initialize(context:) + @context = context + end # Identifier used as parameter in the CLI to skip from executing def self.id raise NotImplementedError end - def initialize(context:, options:) - @context = context - @options = options - end - # Initiate a backup # # @param [Pathname] backup_path a path where to store the backups - # @param [String] backup_id - def backup!(backup_path, backup_id) + def backup!(backup_path) backup_output = backup_path.join(destination_path) # During test, we ensure storage exists so we can run against `RAILS_ENV=test` environment - FileUtils.mkdir_p(storage_path) if context.env.test? && respond_to?(:storage_path, true) + FileUtils.mkdir_p(storage_path) if context&.env&.test? && respond_to?(:storage_path, true) - target.dump(backup_output, backup_id) + target.dump(backup_output) end - def restore!(archive_directory, backup_id) + def restore!(archive_directory) archived_data_location = Pathname(archive_directory).join(destination_path) - target.restore(archived_data_location, backup_id) + target.restore(archived_data_location) end # Key string that identifies the task @@ -70,7 +68,10 @@ def enabled? end def config - context.config(id) + return context.config(id) if context + + Output.warning("No context passed to derive configuration from.") + nil end def object_storage? diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/terraform_state.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/terraform_state.rb index be821ca5966dc..01cc7cf18e48f 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/terraform_state.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/terraform_state.rb @@ -14,7 +14,7 @@ def destination_path = 'terraform_state.tar.gz' private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options, excludes: ['tmp']) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path, excludes: ['tmp']) end def storage_path = context.terraform_state_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/uploads.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/uploads.rb index 2846528073d1b..e2979fad614c8 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/uploads.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/tasks/uploads.rb @@ -14,7 +14,7 @@ def destination_path = 'uploads.tar.gz' private def local - ::Backup::Targets::Files.new(nil, storage_path, options: options, excludes: ['tmp']) + Gitlab::Backup::Cli::Targets::Files.new(context, storage_path, excludes: ['tmp']) end def storage_path = context.upload_path diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils/tar.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils/tar.rb index afaf32357c42d..aa0bfb6417632 100644 --- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils/tar.rb +++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/utils/tar.rb @@ -49,6 +49,14 @@ def pack_cmd(archive_file:, target_directory:, target:, excludes: []) Shell::Command.new(cmd, *tar_args) end + def pack_from_stdin_cmd(target_directory:, target:, excludes: []) + pack_cmd( + archive_file: '-', # use stdin as list of files + target_directory: target_directory, + target: target, + excludes: excludes) + end + # @param [Object] archive_file # @param [Object] target_directory # @return [Gitlab::Backup::Cli::Shell::Command] @@ -64,6 +72,11 @@ def extract_cmd(archive_file:, target_directory:) Shell::Command.new(cmd, *tar_args) end + def extract_from_stdin_cmd(target_directory:) + extract_cmd(archive_file: '-', # use stdin as file source content + target_directory: target_directory) + end + private def build_exclude_patterns(*patterns) diff --git a/gems/gitlab-backup-cli/spec/fixtures/gitlab.yml b/gems/gitlab-backup-cli/spec/fixtures/config/gitlab.yml similarity index 100% rename from gems/gitlab-backup-cli/spec/fixtures/gitlab.yml rename to gems/gitlab-backup-cli/spec/fixtures/config/gitlab.yml diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/gitlab_config_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/gitlab_config_spec.rb index a7ec31af651a2..dc0af3c862353 100644 --- a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/gitlab_config_spec.rb +++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/gitlab_config_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Gitlab::Backup::Cli::GitlabConfig do - let(:config_fixture) { fixtures_path.join('gitlab.yml') } + let(:config_fixture) { fixtures_path.join('config/gitlab.yml') } subject(:gitlab_config) { described_class.new(config_fixture) } diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/files_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/files_spec.rb new file mode 100644 index 0000000000000..d9639aee089be --- /dev/null +++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/files_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'active_support/testing/time_helpers' + +RSpec.describe Gitlab::Backup::Cli::Targets::Files, feature_category: :backup_restore do + include ActiveSupport::Testing::TimeHelpers + + let(:status_0) { instance_double(Process::Status, success?: true, exitstatus: 0) } + let(:status_1) { instance_double(Process::Status, success?: false, exitstatus: 1) } + let(:status_2) { instance_double(Process::Status, success?: false, exitstatus: 2) } + let(:pipeline_status_failed) do + Gitlab::Backup::Cli::Shell::Pipeline::Result.new(stderr: 'Cannot mkdir', status_list: [status_1, status_0]) + end + + let(:tmp_backup_restore_dir) { Dir.mktmpdir('files-target-restore') } + + let(:destination) { 'registry.tar.gz' } + + let(:context) { Gitlab::Backup::Cli::Context.build } + + let!(:workdir) do + FileUtils.mkdir_p(context.backup_basedir) + Pathname(Dir.mktmpdir('backup', context.backup_basedir)) + end + + let(:restore_target) { File.realpath(tmp_backup_restore_dir) } + + let(:backup_target) do + %w[@pages.tmp lost+found @hashed].each do |folder| + path = Pathname(tmp_backup_restore_dir).join(folder, 'something', 'else') + + FileUtils.mkdir_p(path) + FileUtils.touch(path.join('artifacts.zip')) + end + + File.realpath(tmp_backup_restore_dir) + end + + before do + allow(FileUtils).to receive(:mv).and_return(true) + allow(File).to receive(:exist?).and_return(true) + end + + after do + FileUtils.rm_rf([restore_target, backup_target, destination], secure: true) + end + + describe '#dump' do + subject(:files) do + described_class.new(context, backup_target, excludes: ['@pages.tmp']) + end + + it 'raises no errors' do + expect { files.dump(destination) }.not_to raise_error + end + + it 'excludes tmp dirs from archive' do + expect_next_instance_of(Gitlab::Backup::Cli::Shell::Pipeline) do |pipeline| + tar_cmd = pipeline.shell_commands[0] + + expect(tar_cmd.cmd_args).to include('--exclude=lost+found') + expect(tar_cmd.cmd_args).to include('--exclude=./@pages.tmp') + + allow(pipeline).to receive(:run!).and_call_original + end + + files.dump(destination) + end + + it 'raises an error on failure' do + expect_next_instance_of(Gitlab::Backup::Cli::Shell::Pipeline::Result) do |result| + expect(result).to receive(:success?).and_return(false) + end + + expect do + files.dump(destination) + end.to raise_error(/Failed to create compressed file/) + end + end + + describe '#restore' do + let(:source) { File.join(restore_target, 'backup.tar.gz') } + let(:pipeline) { Gitlab::Backup::Cli::Shell::Pipeline.new(Gitlab::Backup::Cli::Shell::Command.new('echo 0')) } + + subject(:files) { described_class.new(context, restore_target) } + + before do + FileUtils.touch(source) + allow(Gitlab::Backup::Cli::Shell::Pipeline).to receive(:new).and_return(pipeline) + end + + context 'when storage path exists' do + before do + allow(File).to receive(:exist?).with(restore_target).and_return(true) + end + + it 'logs a warning about existing files' do + expect(Gitlab::Backup::Cli::Output).to receive(:warning).with(/Ignoring existing files/) + + files.restore(source) + end + end + + context 'when pipeline execution is successful' do + before do + allow_next_instance_of(Gitlab::Backup::Cli::Shell::Pipeline::Result) do |result| + allow(result).to receive(:success?).and_return(true) + end + end + + it 'does not raise an error' do + expect { files.restore(source) }.not_to raise_error + end + end + + context 'when pipeline execution fails' do + before do + allow(files).to receive(:dump).and_return(true) + allow_next_instance_of(Gitlab::Backup::Cli::Shell::Pipeline) do |pipeline| + allow(pipeline).to receive(:run!).and_return(pipeline_status_failed) + end + end + + it 'raises a FileRestoreError' do + expect { files.restore(source) }.to raise_error(Gitlab::Backup::Cli::Errors::FileRestoreError) + end + end + + context 'when pipeline execution has non-critical warnings' do + let(:warning_message) { 'tar: .: Cannot mkdir: No such file or directory' } + + before do + allow_next_instance_of(Gitlab::Backup::Cli::Shell::Pipeline::Result) do |result| + allow(result).to receive(:success?).and_return(false) + allow(result).to receive(:stderr).and_return(warning_message) + allow(result).to receive(:status_list).and_return([status_0, status_2]) + end + end + + it 'does not raise an error' do + expect { files.restore(source) }.not_to raise_error + end + end + end + + describe '#ignore_non_success?' do + subject(:files) do + described_class.new(context, '/var/gitlab-registry') + end + + context 'if `tar` command exits with 1 exitstatus' do + it 'returns true' do + expect( + files.send(:ignore_non_success?, 1, nil) + ).to be_truthy + end + + it 'outputs a warning' do + expect do + files.send(:ignore_non_success?, 1, nil) + end.to output(/Ignoring tar exit status 1/).to_stdout + end + end + + context 'if `tar` command exits with 2 exitstatus with non-critical warning' do + it 'returns true' do + expect( + files.send(:ignore_non_success?, 2, 'gtar: .: Cannot mkdir: No such file or directory') + ).to be_truthy + end + + it 'outputs a warning' do + expect do + files.send(:ignore_non_success?, 2, 'gtar: .: Cannot mkdir: No such file or directory') + end.to output(/Ignoring non-success exit status/).to_stdout + end + end + + context 'if `tar` command exits with any other unlisted error' do + it 'returns false' do + expect( + files.send(:ignore_non_success?, 2, 'unlisted_error') + ).to be_falsey + end + end + end +end diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/gitaly_backup_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/gitaly_backup_spec.rb new file mode 100644 index 0000000000000..ab8ad369910b4 --- /dev/null +++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/gitaly_backup_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' + +RSpec.describe Gitlab::Backup::Cli::Targets::GitalyBackup do + let(:context) { Gitlab::Backup::Cli::Context.build } + let(:gitaly_backup) { described_class.new(context) } + + describe '#start' do + context 'when creating a backup' do + it 'starts the gitaly-backup process with the correct arguments' do + backup_repos_path = '/path/to/backup/repos' + backup_id = 'abc123' + expected_args = ['create', '-path', backup_repos_path, '-layout', 'manifest', '-id', backup_id] + expect(Open3).to receive(:popen2).with(instance_of(Hash), instance_of(String), *expected_args) + + gitaly_backup.start(:create, backup_repos_path, backup_id: backup_id) + end + end + + context 'when restoring a backup' do + it 'starts the gitaly-backup process with the correct arguments' do + backup_repos_path = '/path/to/backup/repos' + backup_id = 'abc123' + remove_all_repositories = %w[repo1 repo2] + expected_args = ['restore', '-path', backup_repos_path, '-layout', 'manifest', '-remove-all-repositories', + 'repo1,repo2', '-id', backup_id] + expect(Open3).to receive(:popen2).with(instance_of(Hash), instance_of(String), *expected_args) + + gitaly_backup.start(:restore, backup_repos_path, backup_id: backup_id, + remove_all_repositories: remove_all_repositories) + end + end + + context 'when an invalid type is provided' do + it 'raises an error' do + expect do + gitaly_backup.start(:invalid, + '/path/to/backup/repos') + end.to raise_error(Gitlab::Backup::Cli::Errors::GitalyBackupError, /unknown backup type: invalid/) + end + end + + context 'when already started' do + it 'raises an error' do + gitaly_backup.instance_variable_set(:@thread, Thread.new { true }) + expect do + gitaly_backup.start(:create, + '/path/to/backup/repos') + end.to raise_error(Gitlab::Backup::Cli::Errors::GitalyBackupError, /already started/) + end + end + end + + describe '#finish!' do + context 'when not started' do + it 'returns without raising an error' do + expect { gitaly_backup.finish! }.not_to raise_error + end + end + + context 'when started' do + let(:thread) { instance_double('Thread', join: nil, value: instance_double(Process::Status, exitstatus: 0)) } + + before do + gitaly_backup.instance_variable_set(:@thread, thread) + gitaly_backup.instance_variable_set(:@input_stream, instance_double('InputStream', close: nil)) + end + + it 'closes the input stream and joins the thread' do + input_stream = gitaly_backup.instance_variable_get(:@input_stream) + expect(input_stream).to receive(:close) + expect(thread).to receive(:join) + + gitaly_backup.finish! + end + + context 'when the process exits with a non-zero status' do + let(:thread) { instance_double('Thread', join: nil, value: instance_double(Process::Status, exitstatus: 1)) } + + it 'raises an error' do + expect do + gitaly_backup.finish! + end.to raise_error(Gitlab::Backup::Cli::Errors::GitalyBackupError, /gitaly-backup exit status 1/) + end + end + end + end + + describe '#enqueue' do + context 'when not started' do + it 'raises an error' do + expect do + gitaly_backup.enqueue(double, :project) + end.to raise_error(Gitlab::Backup::Cli::Errors::GitalyBackupError, /not started/) + end + end + + context 'when started' do + let(:input_stream) { instance_double('InputStream', puts: nil) } + + before do + gitaly_backup.instance_variable_set(:@input_stream, input_stream) + gitaly_backup.instance_variable_set(:@thread, Thread.new { true }) + end + + context 'with a project repository' do + let(:container) do + instance_double('Project', repository_storage: 'storage', disk_path: 'disk/path', full_path: 'group/project') + end + + it 'schedules a backup job with the correct parameters' do + expected_json = { + storage_name: 'storage', + relative_path: 'disk/path', + gl_project_path: 'group/project', + always_create: true + }.to_json + + expect(input_stream).to receive(:puts).with(expected_json) + + gitaly_backup.enqueue(container, :project) + end + end + + context 'with a wiki repository' do + let(:wiki) do + instance_double('Wiki', repository_storage: 'wiki_storage', disk_path: 'wiki/disk/path', + full_path: 'group/project.wiki') + end + + let(:container) { instance_double('Project', wiki: wiki) } + + it 'schedules a backup job with the correct parameters' do + expected_json = { + storage_name: 'wiki_storage', + relative_path: 'wiki/disk/path', + gl_project_path: 'group/project.wiki', + always_create: false + }.to_json + + expect(input_stream).to receive(:puts).with(expected_json) + + gitaly_backup.enqueue(container, :wiki) + end + end + + context 'with a snippet repository' do + let(:container) do + instance_double('Snippet', repository_storage: 'storage', disk_path: 'disk/path', full_path: 'snippets/1') + end + + it 'schedules a backup job with the correct parameters' do + expected_json = { + storage_name: 'storage', + relative_path: 'disk/path', + gl_project_path: 'snippets/1', + always_create: false + }.to_json + + expect(input_stream).to receive(:puts).with(expected_json) + + gitaly_backup.enqueue(container, :snippet) + end + end + + context 'with a design repository' do + let(:project) { instance_double('Project', disk_path: 'disk/path', full_path: 'group/project') } + let(:container) do + instance_double('DesignRepository', project: project, + repository: instance_double('Repository', repository_storage: 'storage')) + end + + it 'schedules a backup job with the correct parameters' do + expected_json = { + storage_name: 'storage', + relative_path: 'disk/path', + gl_project_path: 'group/project', + always_create: false + }.to_json + + expect(input_stream).to receive(:puts).with(expected_json) + + gitaly_backup.enqueue(container, :design) + end + end + + context 'with an invalid repository type' do + it 'raises an error' do + expect do + gitaly_backup.enqueue(nil, + :invalid) + end.to raise_error(Gitlab::Backup::Cli::Errors::GitalyBackupError, /no container for repo type/) + end + end + end + end +end diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/object_storage/google_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/object_storage/google_spec.rb index 1ef0090d93a25..f034ff184fadd 100644 --- a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/object_storage/google_spec.rb +++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/object_storage/google_spec.rb @@ -55,17 +55,15 @@ } end - let(:backup_options) { instance_double("::Backup::Options", remote_directory: 'fake_backup_bucket') } - before do allow(Gitlab).to receive(:config).and_return(gitlab_config) allow(::Google::Cloud::StorageTransfer).to receive(:storage_transfer_service).and_return(client) allow(gitlab_config).to receive(:[]).with('fake_object').and_return(supported_config) end - subject(:object_storage) { described_class.new("fake_object", backup_options, supported_config) } + subject(:object_storage) { described_class.new("fake_object", 'fake_backup_bucket', supported_config) } - describe "#dump" do + describe "#dump", :silence_output do context "when job exists" do before do allow(client).to receive(:get_transfer_job).and_return(backup_transfer_job) @@ -79,7 +77,7 @@ transfer_job: updated_spec ) expect(client).to receive(:run_transfer_job).with({ job_name: "fake_transfer_job", project_id: "fake_project" }) - object_storage.dump(nil, 12345) + object_storage.dump(12345) end end @@ -94,12 +92,12 @@ it "creates a new job" do expect(client).to receive(:create_transfer_job) .with(transfer_job: new_backup_transfer_job_spec).and_return(backup_transfer_job) - object_storage.dump(nil, 12345) + object_storage.dump(12345) end end end - describe "#restore" do + describe "#restore", :silence_output do context "when job exists" do before do allow(client).to receive(:get_transfer_job).and_return(restore_transfer_job) @@ -113,7 +111,7 @@ transfer_job: updated_spec ) expect(client).to receive(:run_transfer_job).with({ job_name: "fake_transfer_job", project_id: "fake_project" }) - object_storage.restore(nil, 12345) + object_storage.restore(12345) end end @@ -128,7 +126,7 @@ it "creates a new job" do expect(client).to receive(:create_transfer_job) .with(transfer_job: new_restore_transfer_job_spec).and_return(restore_transfer_job) - object_storage.restore(nil, 12345) + object_storage.restore(12345) end end end diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/repositories_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/repositories_spec.rb new file mode 100644 index 0000000000000..160dd1802307a --- /dev/null +++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/targets/repositories_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Backup::Cli::Targets::Repositories do + let(:context) { Gitlab::Backup::Cli::Context.build } + let(:strategy) { repo_target.strategy } + + subject(:repo_target) { described_class.new(context) } + + describe '#dump' do + it 'starts and finishes the strategy' do + expect(strategy).to receive(:start).with(:create, '/path/to/destination') + expect(repo_target).to receive(:enqueue_consecutive) + expect(strategy).to receive(:finish!) + + repo_target.dump('/path/to/destination') + end + end + + describe '#restore' do + it 'starts and finishes the strategy' do + expect(strategy).to receive(:start).with(:restore, '/path/to/destination', remove_all_repositories: ["default"]) + expect(repo_target).to receive(:enqueue_consecutive) + expect(strategy).to receive(:finish!) + expect(repo_target).to receive(:restore_object_pools) + + repo_target.restore('/path/to/destination') + end + end + + describe '#enqueue_consecutive' do + it 'calls enqueue_consecutive_projects and enqueue_consecutive_snippets' do + expect(repo_target).to receive(:enqueue_consecutive_projects) + expect(repo_target).to receive(:enqueue_consecutive_snippets) + + repo_target.send(:enqueue_consecutive) + end + end + + describe '#enqueue_project' do + let(:project) { instance_double('Project', design_management_repository: nil) } + + it 'enqueues project and wiki' do + expect(strategy).to receive(:enqueue).with(project, Gitlab::Backup::Cli::RepoType::PROJECT) + expect(strategy).to receive(:enqueue).with(project, Gitlab::Backup::Cli::RepoType::WIKI) + + repo_target.send(:enqueue_project, project) + end + + context 'when project has design management repository' do + let(:design_repo) { instance_double('DesignRepository') } + let(:project) { instance_double('Project', design_management_repository: design_repo) } + + it 'enqueues project, wiki, and design' do + expect(strategy).to receive(:enqueue).with(project, Gitlab::Backup::Cli::RepoType::PROJECT) + expect(strategy).to receive(:enqueue).with(project, Gitlab::Backup::Cli::RepoType::WIKI) + expect(strategy).to receive(:enqueue).with(design_repo, Gitlab::Backup::Cli::RepoType::DESIGN) + + repo_target.send(:enqueue_project, project) + end + end + end + + describe '#enqueue_snippet' do + let(:snippet) { instance_double('Snippet') } + + it 'enqueues the snippet' do + expect(strategy).to receive(:enqueue).with(snippet, Gitlab::Backup::Cli::RepoType::SNIPPET) + + repo_target.send(:enqueue_snippet, snippet) + end + end +end diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/tasks/task_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/tasks/task_spec.rb index 2148df8cafd9d..2ac3cc4e96e62 100644 --- a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/tasks/task_spec.rb +++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/tasks/task_spec.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true RSpec.describe Gitlab::Backup::Cli::Tasks::Task do - let(:options) { instance_double("::Backup::Option", backup_id: "abc123") } let(:context) { build_fake_context } let(:tmpdir) { Pathname.new(Dir.mktmpdir('task', temp_path)) } let(:metadata) { build(:backup_metadata) } - subject(:task) { described_class.new(options: options, context: context) } + subject(:task) { described_class.new(context: context) } after do FileUtils.rmtree(tmpdir) @@ -37,9 +36,9 @@ end end - describe '#target' do + describe '#local' do it 'raises an error' do - expect { task.send(:target) }.to raise_error(NotImplementedError) + expect { task.send(:local) }.to raise_error(NotImplementedError) end end end @@ -49,7 +48,7 @@ expect(task).to receive(:destination_path).and_return(tmpdir.join('test_task')) expect(task).to receive_message_chain(:target, :dump) - task.backup!(tmpdir, metadata.backup_id) + task.backup!(tmpdir) end end @@ -59,7 +58,7 @@ expect(task).to receive(:destination_path).and_return(tmpdir.join('test_task')) expect(task).to receive_message_chain(:target, :restore) - task.restore!(archive_directory, options.backup_id) + task.restore!(archive_directory) end end end diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/utils/tar_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/utils/tar_spec.rb index 374ea69281e02..fc0fd8e374f0b 100644 --- a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/utils/tar_spec.rb +++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/utils/tar_spec.rb @@ -20,9 +20,13 @@ def tar_tempdir target_basepath = tempdir target = tempdir.join('*') + result = nil + expect do - tar.pack_cmd(archive_file: archive_file, target_directory: target_basepath, target: target) + result = tar.pack_cmd(archive_file: archive_file, target_directory: target_basepath, target: target) end.not_to raise_exception + + expect(result).to be_a(Gitlab::Backup::Cli::Shell::Command) end end @@ -83,4 +87,51 @@ def tar_tempdir end end end + + describe '#pack_from_stdin_cmd' do + it 'delegates parameters to pack_cmd passing archive_files: as -' do + tar_tempdir do |tempdir| + target_basepath = tempdir + target = tempdir.join('*') + excludes = ['lost+found'] + + expect(tar).to receive(:pack_cmd).with( + archive_file: '-', + target_directory: target_basepath, + target: target, + excludes: excludes) + + tar.pack_from_stdin_cmd(target_directory: target_basepath, target: target, excludes: excludes) + end + end + end + + describe '#extract_cmd' do + it 'instantiate a Shell::Command with default required params' do + tar_tempdir do |tempdir| + archive_file = tempdir.join('testarchive.tar') + target_basepath = tempdir + + result = nil + + expect do + result = tar.extract_cmd(archive_file: archive_file, target_directory: target_basepath) + end.not_to raise_exception + + expect(result).to be_a(Gitlab::Backup::Cli::Shell::Command) + end + end + end + + describe 'extract_from_stdin_cmd' do + it 'delegates parameters to extract_cmd passing archive_files: as -' do + tar_tempdir do |tempdir| + target_basepath = tempdir + + expect(tar).to receive(:extract_cmd).with(archive_file: '-', target_directory: target_basepath) + + tar.extract_from_stdin_cmd(target_directory: target_basepath) + end + end + end end diff --git a/gems/gitlab-backup-cli/spec/spec_helper.rb b/gems/gitlab-backup-cli/spec/spec_helper.rb index a43e0477bdd63..870a795ce4453 100644 --- a/gems/gitlab-backup-cli/spec/spec_helper.rb +++ b/gems/gitlab-backup-cli/spec/spec_helper.rb @@ -7,6 +7,7 @@ require 'gitlab/rspec/next_instance_of' ENV["RAILS_ENV"] ||= "test" +GITLAB_PATH = File.expand_path(File.join(__dir__, '/fixtures/')) # Load spec support code Dir['spec/support/**/*.rb'].each { |f| load f } diff --git a/gems/gitlab-backup-cli/spec/support/shared_examples/context_shared_examples.rb b/gems/gitlab-backup-cli/spec/support/shared_examples/context_shared_examples.rb index 1aa7874fbb782..b5e6e6ededbb8 100644 --- a/gems/gitlab-backup-cli/spec/support/shared_examples/context_shared_examples.rb +++ b/gems/gitlab-backup-cli/spec/support/shared_examples/context_shared_examples.rb @@ -24,7 +24,7 @@ describe '#backup_basedir' do context 'with a relative path configured in gitlab.yml' do it 'returns a full path based on gitlab basepath' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.backup_basedir).to eq(fake_gitlab_basepath.join('tmp/tests/backups')) end @@ -58,7 +58,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.ci_builds_path).to eq(Pathname('/tmp/gitlab/full/builds')) end @@ -84,7 +84,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.ci_job_artifacts_path).to eq(Pathname('/tmp/gitlab/full/artifacts')) end @@ -110,7 +110,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.ci_secure_files_path).to eq(Pathname('/tmp/gitlab/full/ci_secure_files')) end @@ -136,7 +136,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.ci_lfs_path).to eq(Pathname('/tmp/gitlab/full/lfs-objects')) end @@ -162,7 +162,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.packages_path).to eq(Pathname('/tmp/gitlab/full/packages')) end @@ -188,7 +188,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.pages_path).to eq(Pathname('/tmp/gitlab/full/pages')) end @@ -214,7 +214,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.registry_path).to eq(Pathname('/tmp/gitlab/full/registry')) end @@ -240,7 +240,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.terraform_state_path).to eq(Pathname('/tmp/gitlab/full/terraform_state')) end @@ -266,7 +266,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.upload_path).to eq(Pathname('/tmp/gitlab/full/public/uploads')) end @@ -292,7 +292,7 @@ context 'with a full path configured in gitlab.yml' do it 'returns a full path as configured in gitlab.yml' do - use_gitlab_config_fixture('gitlab.yml') + use_gitlab_config_fixture('config/gitlab.yml') expect(context.send(:gitlab_shared_path)).to eq(Pathname('/tmp/gitlab/full/shared')) end diff --git a/shared/artifacts/tmp/cache/.gitkeep b/shared/artifacts/tmp/cache/.gitkeep deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/shared/artifacts/tmp/uploads/.gitkeep b/shared/artifacts/tmp/uploads/.gitkeep deleted file mode 100644 index e69de29bb2d1d..0000000000000 -- GitLab