diff --git a/.rubocop_todo/rspec/feature_category.yml b/.rubocop_todo/rspec/feature_category.yml
index 3967d117d3b..817140e8e89 100644
--- a/.rubocop_todo/rspec/feature_category.yml
+++ b/.rubocop_todo/rspec/feature_category.yml
@@ -4187,7 +4187,6 @@ RSpec/FeatureCategory:
- 'spec/views/shared/_label_row.html.haml_spec.rb'
- 'spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb'
- 'spec/views/shared/gitlab_version/_security_patch_upgrade_alert.html.haml_spec.rb'
- - 'spec/views/shared/groups/_dropdown.html.haml_spec.rb'
- 'spec/views/shared/issuable/_sidebar.html.haml_spec.rb'
- 'spec/views/shared/milestones/_issuable.html.haml_spec.rb'
- 'spec/views/shared/milestones/_issuables.html.haml_spec.rb'
diff --git a/app/assets/javascripts/admin/groups/components/filtered_search_and_sort.vue b/app/assets/javascripts/admin/groups/components/filtered_search_and_sort.vue
new file mode 100644
index 00000000000..dbcc081461d
--- /dev/null
+++ b/app/assets/javascripts/admin/groups/components/filtered_search_and_sort.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/admin/groups/constants.js b/app/assets/javascripts/admin/groups/constants.js
new file mode 100644
index 00000000000..be10a8907eb
--- /dev/null
+++ b/app/assets/javascripts/admin/groups/constants.js
@@ -0,0 +1,39 @@
+import { __ } from '~/locale';
+
+export const FILTERED_SEARCH_NAMESPACE = 'admin-groups';
+export const FILTERED_SEARCH_TERM_KEY = 'name';
+
+export const SORT_DIRECTION_ASC = 'asc';
+export const SORT_DIRECTION_DESC = 'desc';
+
+const NAME = 'name';
+const CREATED = 'created';
+const LATEST_ACTIVITY = 'latest_activity';
+const STORAGE_SIZE = 'storage_size';
+
+export const SORT_OPTION_NAME = {
+ text: __('Name'),
+ value: NAME,
+};
+
+export const SORT_OPTION_CREATED_DATE = {
+ text: __('Created date'),
+ value: CREATED,
+};
+
+export const SORT_OPTION_UPDATED_DATE = {
+ text: __('Updated date'),
+ value: LATEST_ACTIVITY,
+};
+
+export const SORT_OPTION_STORAGE_SIZE = {
+ text: __('Storage size'),
+ value: STORAGE_SIZE,
+};
+
+export const SORT_OPTIONS = [
+ SORT_OPTION_NAME,
+ SORT_OPTION_CREATED_DATE,
+ SORT_OPTION_UPDATED_DATE,
+ SORT_OPTION_STORAGE_SIZE,
+];
diff --git a/app/assets/javascripts/admin/groups/index.js b/app/assets/javascripts/admin/groups/index.js
new file mode 100644
index 00000000000..1ceb105bdbf
--- /dev/null
+++ b/app/assets/javascripts/admin/groups/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import FilteredSearchAndSort from './components/filtered_search_and_sort.vue';
+
+export const initAdminGroupsFilteredSearchAndSort = () => {
+ const el = document.getElementById('js-admin-groups-filtered-search-and-sort');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ name: 'AdminGroupsFilteredSearchAndSort',
+ render(createElement) {
+ return createElement(FilteredSearchAndSort);
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/components/registration/gke_node_pool_group.vue b/app/assets/javascripts/ci/runner/components/registration/gke_node_pool_group.vue
index 79c8528a8e5..8e7caf0a24b 100644
--- a/app/assets/javascripts/ci/runner/components/registration/gke_node_pool_group.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/gke_node_pool_group.vue
@@ -199,7 +199,7 @@ export default {
<name>_<type(optional)>
- ubuntu
+ ubuntu_containerd
diff --git a/app/assets/javascripts/merge_request_dashboard/components/app.vue b/app/assets/javascripts/merge_request_dashboard/components/app.vue
index fb015556470..2eb3ffe5878 100644
--- a/app/assets/javascripts/merge_request_dashboard/components/app.vue
+++ b/app/assets/javascripts/merge_request_dashboard/components/app.vue
@@ -1,5 +1,6 @@
@@ -43,22 +50,24 @@ export default {
+
+
+
diff --git a/app/assets/javascripts/merge_request_dashboard/components/collapsible_section.vue b/app/assets/javascripts/merge_request_dashboard/components/collapsible_section.vue
index 1150296c59c..7d33b5c1c2c 100644
--- a/app/assets/javascripts/merge_request_dashboard/components/collapsible_section.vue
+++ b/app/assets/javascripts/merge_request_dashboard/components/collapsible_section.vue
@@ -25,11 +25,6 @@ export default {
required: false,
default: null,
},
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -86,9 +81,14 @@ export default {
@click="toggleOpen"
/>
{{ title }}
- {{
- count
- }}
+ {{ count }}
-import reviewerQuery from '../queries/reviewer.query.graphql';
-import reviewerCountQuery from '../queries/reviewer_count.query.graphql';
-import assigneeQuery from '../queries/assignee.query.graphql';
-import assigneeCountQuery from '../queries/assignee_count.query.graphql';
-import assigneeOrReviewerQuery from '../queries/assignee_or_reviewer.query.graphql';
-import assigneeOrReviewerCountQuery from '../queries/assignee_or_reviewer_count.query.graphql';
+import { QUERIES } from '../constants';
const PER_PAGE = 20;
-const QUERIES = {
- reviewRequestedMergeRequests: { dataQuery: reviewerQuery, countQuery: reviewerCountQuery },
- assignedMergeRequests: { dataQuery: assigneeQuery, countQuery: assigneeCountQuery },
- assigneeOrReviewerMergeRequests: {
- dataQuery: assigneeOrReviewerQuery,
- countQuery: assigneeOrReviewerCountQuery,
- },
-};
-
export default {
apollo: {
mergeRequests: {
@@ -37,6 +23,9 @@ export default {
},
},
count: {
+ context: {
+ batchKey: 'MergeRequestListsCounts',
+ },
query() {
return QUERIES[this.query].countQuery;
},
@@ -49,6 +38,9 @@ export default {
perPage: PER_PAGE,
};
},
+ skip() {
+ return this.hideCount;
+ },
},
},
props: {
@@ -60,6 +52,11 @@ export default {
type: Object,
required: true,
},
+ hideCount: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
diff --git a/app/assets/javascripts/merge_request_dashboard/components/tab_title.vue b/app/assets/javascripts/merge_request_dashboard/components/tab_title.vue
new file mode 100644
index 00000000000..6ecd960e22c
--- /dev/null
+++ b/app/assets/javascripts/merge_request_dashboard/components/tab_title.vue
@@ -0,0 +1,57 @@
+
+
+
+
+ {{ title }}
+ {{
+ loading ? '-' : count
+ }}
+
+
diff --git a/app/assets/javascripts/merge_request_dashboard/constants.js b/app/assets/javascripts/merge_request_dashboard/constants.js
new file mode 100644
index 00000000000..49d709b2be5
--- /dev/null
+++ b/app/assets/javascripts/merge_request_dashboard/constants.js
@@ -0,0 +1,15 @@
+import reviewerQuery from './queries/reviewer.query.graphql';
+import reviewerCountQuery from './queries/reviewer_count.query.graphql';
+import assigneeQuery from './queries/assignee.query.graphql';
+import assigneeCountQuery from './queries/assignee_count.query.graphql';
+import assigneeOrReviewerQuery from './queries/assignee_or_reviewer.query.graphql';
+import assigneeOrReviewerCountQuery from './queries/assignee_or_reviewer_count.query.graphql';
+
+export const QUERIES = {
+ reviewRequestedMergeRequests: { dataQuery: reviewerQuery, countQuery: reviewerCountQuery },
+ assignedMergeRequests: { dataQuery: assigneeQuery, countQuery: assigneeCountQuery },
+ assigneeOrReviewerMergeRequests: {
+ dataQuery: assigneeOrReviewerQuery,
+ countQuery: assigneeOrReviewerCountQuery,
+ },
+};
diff --git a/app/assets/javascripts/pages/admin/groups/index/index.js b/app/assets/javascripts/pages/admin/groups/index/index.js
index 36c70a86643..c438d0dd15c 100644
--- a/app/assets/javascripts/pages/admin/groups/index/index.js
+++ b/app/assets/javascripts/pages/admin/groups/index/index.js
@@ -1,3 +1,5 @@
import initConfirmDanger from '~/init_confirm_danger';
+import { initAdminGroupsFilteredSearchAndSort } from '~/admin/groups/index';
initConfirmDanger();
+initAdminGroupsFilteredSearchAndSort();
diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
index a5459f98f0a..6ee91212bd3 100644
--- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
+++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue
@@ -198,8 +198,8 @@ export default {
-
-
+
+
-
+
-
-
+
+
{{ reference }}
import(
'ee_component/work_items/components/work_item_links/work_item_rolled_up_health_status.vue'
@@ -44,10 +42,6 @@ export default {
required: false,
default: null,
},
- rolledUpCountsByType: {
- type: Array,
- required: true,
- },
},
data() {
return {
@@ -113,17 +107,13 @@ export default {
-
-
-
-
{{ rolledUpWeight }}
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index c7edd4a9a55..c252c9b4f91 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -32,6 +32,7 @@ import WorkItemActionsSplitButton from './work_item_actions_split_button.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
import WorkItemChildrenWrapper from './work_item_children_wrapper.vue';
import WorkItemRolledUpData from './work_item_rolled_up_data.vue';
+import WorkItemRolledUpCount from './work_item_rolled_up_count.vue';
export default {
FORM_TYPES,
@@ -47,6 +48,7 @@ export default {
WorkItemChildrenLoadMore,
WorkItemMoreActions,
WorkItemRolledUpData,
+ WorkItemRolledUpCount,
},
inject: ['hasSubepicsFeature'],
provide() {
@@ -331,12 +333,28 @@ export default {
data-testid="work-item-tree"
>
+
+
+
+
+
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index f9cb7ba7d0b..e71d1e5b278 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -176,7 +176,7 @@
}
.job-log-line-number {
- color: $gray-500;
+ @apply gl-text-gray-500;
padding-right: $gl-padding-8;
margin-right: $gl-padding-8;
min-width: $job-line-number-width;
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 094c7735f84..e6a0adbffae 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -18,7 +18,6 @@ module Projects
urgency :low, [:show, :create_deploy_token]
def show
- Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/482942')
render_show
end
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index 0b58ebc1fb2..b2ad8c8fe3d 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -21,10 +21,6 @@ module ExploreHelper
request_path_with_options(options)
end
- def filter_groups_path(options = {})
- request_path_with_options(options)
- end
-
def public_visibility_restricted?
Gitlab::VisibilityLevel.public_visibility_restricted?
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index e6c30cf1ca0..96ea898699c 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -503,6 +503,7 @@ module MergeRequestsHelper
helpContent: _('These merge requests merged after %{date}. You were an assignee or a reviewer.') % {
date: 2.weeks.ago.to_date.to_formatted_s(:long)
},
+ hideCount: true,
query: 'assigneeOrReviewerMergeRequests',
variables: {
state: 'merged',
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 7798f3905da..668fd204c7d 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -73,23 +73,6 @@ module SortingHelper
}
end
- def groups_sort_options_hash
- {
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_recently_created => sort_title_recently_created,
- sort_value_oldest_created => sort_title_oldest_created,
- sort_value_latest_activity => sort_title_recently_updated,
- sort_value_oldest_activity => sort_title_oldest_updated
- }
- end
-
- def admin_groups_sort_options_hash
- groups_sort_options_hash.merge(
- sort_value_largest_group => sort_title_largest_group
- )
- end
-
def milestones_sort_options_hash
{
sort_value_due_date_soon => sort_title_due_date_soon,
diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml
index 2e7584eeac2..c32fef99c7b 100644
--- a/app/views/admin/groups/index.html.haml
+++ b/app/views/admin/groups/index.html.haml
@@ -6,14 +6,9 @@
= link_button_to new_admin_group_path, variant: :confirm do
= _('New group')
-.md:gl-flex.gl-min-w-0.gl-grow.row-content-block
- = form_tag admin_groups_path, method: :get, class: 'js-search-form gl-w-full' do |f|
- = hidden_field_tag :sort, @sort
- .search-holder
- .search-field-holder
- = search_field_tag :name, params[:name].presence, class: "form-control search-text-input js-search-input", spellcheck: false, placeholder: 'Search by name', data: { testid: 'group-search-field' }
- = sprite_icon('search', css_class: 'search-icon')
- = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash
+#js-admin-groups-filtered-search-and-sort
+ -# This element takes up space while Vue is rendering to avoid page jump
+ .gl-h-12
- if @groups.any?
%ul.content-list
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
deleted file mode 100644
index 0b39f42165f..00000000000
--- a/app/views/shared/groups/_dropdown.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash)
-- groups_sort_options = options_hash.map { |value, title| { value: value, text: title, href: filter_groups_path(sort: value) } }
-
-%div{ data: { testid: 'group_sort_by_dropdown' } }
- = gl_redirect_listbox_tag groups_sort_options, project_list_sort_by, data: { placement: 'right' }
diff --git a/doc/administration/admin_area.md b/doc/administration/admin_area.md
index f8e7f99eec3..54cec6cd71f 100644
--- a/doc/administration/admin_area.md
+++ b/doc/administration/admin_area.md
@@ -239,8 +239,15 @@ To access the Groups page:
For each group, the page displays their name, description, size, number of projects in the group,
number of members, and whether the group is private, internal, or public. To edit a group, in the group's row, select **Edit**. To delete the group, in the group's row, select **Delete**.
-To change the sort order, select the sort dropdown list and select the desired order. The default
-sort order is by **Last created**.
+To change the sort order, select the sort dropdown list and choose the desired order.
+You can sort groups by:
+
+- Created date (default)
+- Updated date
+- Storage size
+
+The storage size option sorts groups by the total storage used, including Git repositories
+and Large File Storage (LFS) for all projects in the group. For more information, see [usage quotas](../user/storage_usage_quotas.md).
To search for groups by name, enter your criteria in the search field. The group search is case
insensitive, and applies partial matching.
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 60b9bb53394..5999e94afa2 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -458,6 +458,12 @@ This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#pagination-arguments):
`before: String`, `after: String`, `first: Int`, and `last: Int`.
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `projectPath` | [`ID`](#id) | Full path of the project containing the workflows. |
+
### `Query.echo`
Testing endpoint to validate the API with.
diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md
index b9970a27081..b135a7d7a18 100644
--- a/doc/user/application_security/container_scanning/index.md
+++ b/doc/user/application_security/container_scanning/index.md
@@ -269,7 +269,7 @@ positives.
| `CS_REGISTRY_PASSWORD` | `$CI_REGISTRY_PASSWORD` | Password for accessing a Docker registry requiring authentication. The default is only set if `$CS_IMAGE` resides at [`$CI_REGISTRY`](../../../ci/variables/predefined_variables.md). Not supported when [FIPS mode](../../../development/fips_compliance.md#enable-fips-mode) is enabled. |
| `CS_REGISTRY_USER` | `$CI_REGISTRY_USER` | Username for accessing a Docker registry requiring authentication. The default is only set if `$CS_IMAGE` resides at [`$CI_REGISTRY`](../../../ci/variables/predefined_variables.md). Not supported when [FIPS mode](../../../development/fips_compliance.md#enable-fips-mode) is enabled. |
| `CS_SEVERITY_THRESHOLD` | `UNKNOWN` | Severity level threshold. The scanner outputs vulnerabilities with severity level higher than or equal to this threshold. Supported levels are `UNKNOWN`, `LOW`, `MEDIUM`, `HIGH`, and `CRITICAL`. |
-| `CS_TRIVY_JAVA_DB` | `"ghcr.io/aquasecurity/trivy-java-db"` | Specify an alternate location for the [trivy-java-db](https://github.com/aquasecurity/trivy-java-db) vulnerability database. |
+| `CS_TRIVY_JAVA_DB` | `"registry.gitlab.com/gitlab-org/security-products/dependencies/trivy-java-db"` | Specify an alternate location for the [trivy-java-db](https://github.com/aquasecurity/trivy-java-db) vulnerability database. |
| `SECURE_LOG_LEVEL` | `info` | Set the minimum logging level. Messages of this logging level or higher are output. From highest to lowest severity, the logging levels are: `fatal`, `error`, `warn`, `info`, `debug`. |
| `TRIVY_TIMEOUT` | `5m0s` | Set the timeout for the scan. |
diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli.rb
index b8fa1e08fc8..ee6fef06a67 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 Gitlab
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 8df1a3edc89..4d27bdfc231 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 @@ module Gitlab
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 1feb2efbd80..ba3a2caa285 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 @@ module Gitlab
end
def config(object_type)
- Gitlab.config[object_type]
+ gitlab_config[object_type]
end
def env
@@ -112,6 +112,18 @@ module Gitlab
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 68060d7dcb1..6cd73adf3a2 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 Gitlab
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 00000000000..451948fe52b
--- /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 00000000000..318515d934a
--- /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 00000000000..15e9cae7539
--- /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 25a76382d5e..e7cc30ad343 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 @@ module Gitlab
execute_all_tasks
end
- def backup_options
- @backup_options ||= build_backup_options!
- end
-
def metadata
@metadata ||= read_metadata!
end
@@ -57,14 +53,16 @@ module Gitlab
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 @@ module Gitlab
@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 e66a3d78ce5..8a667927676 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 Gitlab
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 5cf484ad92a..ea5dc775782 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 @@ module Gitlab
].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 @@ module Gitlab
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 00000000000..7a6c18b4017
--- /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 00000000000..c3770e6d78a
--- /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 00000000000..5385348386b
--- /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 79001062857..3a823b72df8 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 @@ module Gitlab
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 @@ module Gitlab
@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 00000000000..9cb1b430f80
--- /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 d7757026423..b272552a4e3 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 Gitlab
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 @@ module Gitlab
# 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 14ed04f567c..9af8ceaaa88 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 @@ module Gitlab
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 b94f3848807..ed5dffe5071 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 @@ module Gitlab
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 f819d3fd3ab..55a8f84343d 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 @@ module Gitlab
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 1790b109c03..5c4e94326c9 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 @@ module Gitlab
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 46c96d4c8ec..50cb98eadbe 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 @@ module Gitlab
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 f3bca473d97..6696a23df42 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 @@ module Gitlab
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 f2c12e1d709..e5715efb33c 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 @@ module Gitlab
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 80f97fbb66a..9d6893a969e 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 @@ module Gitlab
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 @@ module Gitlab
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 f4d172d623e..5f20f4c233e 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 @@ module Gitlab
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 1d1fde51fcd..81295abea0b 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 Gitlab
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 @@ module Gitlab
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 be821ca5966..01cc7cf18e4 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 @@ module Gitlab
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 2846528073d..e2979fad614 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 @@ module Gitlab
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 afaf32357c4..aa0bfb64176 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 @@ module Gitlab
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 @@ module Gitlab
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 a7ec31af651..dc0af3c8623 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 00000000000..d9639aee089
--- /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 00000000000..ab8ad369910
--- /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 1ef0090d93a..f034ff184fa 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 @@ RSpec.describe Gitlab::Backup::Cli::Targets::ObjectStorage::Google do
}
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 @@ RSpec.describe Gitlab::Backup::Cli::Targets::ObjectStorage::Google do
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 @@ RSpec.describe Gitlab::Backup::Cli::Targets::ObjectStorage::Google do
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 @@ RSpec.describe Gitlab::Backup::Cli::Targets::ObjectStorage::Google do
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 @@ RSpec.describe Gitlab::Backup::Cli::Targets::ObjectStorage::Google do
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 00000000000..160dd180230
--- /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 2148df8cafd..2ac3cc4e96e 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 @@ RSpec.describe Gitlab::Backup::Cli::Tasks::Task do
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 @@ RSpec.describe Gitlab::Backup::Cli::Tasks::Task do
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 @@ RSpec.describe Gitlab::Backup::Cli::Tasks::Task do
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 374ea69281e..fc0fd8e374f 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 @@ RSpec.describe Gitlab::Backup::Cli::Utils::Tar do
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 @@ RSpec.describe Gitlab::Backup::Cli::Utils::Tar do
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 a43e0477bdd..870a795ce44 100644
--- a/gems/gitlab-backup-cli/spec/spec_helper.rb
+++ b/gems/gitlab-backup-cli/spec/spec_helper.rb
@@ -7,6 +7,7 @@ require 'thor'
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 1aa7874fbb7..b5e6e6ededb 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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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 @@ RSpec.shared_examples "context exposing all common configuration methods" do
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/locale/gitlab.pot b/locale/gitlab.pot
index d51b2aba9dc..4990f06c140 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -26631,9 +26631,6 @@ msgstr ""
msgid "GroupSelect|Select a group"
msgstr ""
-msgid "GroupSettings|'%{name}' has been scheduled for removal on %{date}."
-msgstr ""
-
msgid "GroupSettings|After the instance reaches the user cap, any user who is added or requests access must be approved by an administrator. Leave empty for an unlimited user cap. If you change the user cap to unlimited, you must re-enable %{project_sharing_docs_link_start}project sharing%{link_end} and %{group_sharing_docs_link_start}group sharing%{link_end}."
msgstr ""
@@ -42584,9 +42581,6 @@ msgstr ""
msgid "Project '%{project_name}' was successfully updated."
msgstr ""
-msgid "Project '%{project_name}' will be deleted on %{date}"
-msgstr ""
-
msgid "Project Badges"
msgstr ""
@@ -53434,6 +53428,9 @@ msgstr ""
msgid "Storage nodes for new repositories"
msgstr ""
+msgid "Storage size"
+msgstr ""
+
msgid "Storage:"
msgstr ""
diff --git a/qa/qa/page/admin/overview/groups/index.rb b/qa/qa/page/admin/overview/groups/index.rb
index 6157e7a5e4d..85a727766c9 100644
--- a/qa/qa/page/admin/overview/groups/index.rb
+++ b/qa/qa/page/admin/overview/groups/index.rb
@@ -6,8 +6,8 @@ module QA
module Overview
module Groups
class Index < QA::Page::Base
- view 'app/views/admin/groups/index.html.haml' do
- element 'group-search-field', required: true
+ view 'app/assets/javascripts/admin/groups/components/filtered_search_and_sort.vue' do
+ element 'admin-groups-filtered-search-and-sort', required: true
end
view 'app/views/admin/groups/_group.html.haml' do
@@ -16,7 +16,9 @@ module QA
end
def search_group(group_name)
- find_element('group-search-field').set(group_name).send_keys(:return)
+ within_element('admin-groups-filtered-search-and-sort') do
+ find_element('filtered-search-term-input').set(group_name).send_keys(:return)
+ end
end
def click_group(group_name)
diff --git a/shared/artifacts/tmp/cache/.gitkeep b/shared/artifacts/tmp/cache/.gitkeep
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/shared/artifacts/tmp/uploads/.gitkeep b/shared/artifacts/tmp/uploads/.gitkeep
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb
index ad53e44052f..c7d71395c4c 100644
--- a/spec/controllers/admin/groups_controller_spec.rb
+++ b/spec/controllers/admin/groups_controller_spec.rb
@@ -25,37 +25,6 @@ RSpec.describe Admin::GroupsController, feature_category: :groups_and_projects d
expect(assigns(:groups)).to match_array([group, group_2, group_3])
end
- it 'renders a correct list of sort by options' do
- get :index
-
- html_rendered = Nokogiri::HTML(response.body)
- sort_options = Gitlab::Json.parse(html_rendered.css('[data-items]')[0]['data-items'])
-
- expect(response).to render_template('shared/groups/_dropdown')
-
- expect(sort_options.size).to eq(7)
- expect(sort_options[0]['value']).to eq('name_asc')
- expect(sort_options[0]['text']).to eq(s_('SortOptions|Name'))
-
- expect(sort_options[1]['value']).to eq('name_desc')
- expect(sort_options[1]['text']).to eq(s_('SortOptions|Name, descending'))
-
- expect(sort_options[2]['value']).to eq('created_desc')
- expect(sort_options[2]['text']).to eq(s_('SortOptions|Last created'))
-
- expect(sort_options[3]['value']).to eq('created_asc')
- expect(sort_options[3]['text']).to eq(s_('SortOptions|Oldest created'))
-
- expect(sort_options[4]['value']).to eq('latest_activity_desc')
- expect(sort_options[4]['text']).to eq(_('Updated date'))
-
- expect(sort_options[5]['value']).to eq('latest_activity_asc')
- expect(sort_options[5]['text']).to eq(s_('SortOptions|Oldest updated'))
-
- expect(sort_options[6]['value']).to eq('storage_size_desc')
- expect(sort_options[6]['text']).to eq(s_('SortOptions|Largest group'))
- end
-
context 'when a sort param is present' do
it 'returns a sorted by name_asc result' do
get :index, params: { sort: 'name_asc' }
@@ -119,19 +88,19 @@ RSpec.describe Admin::GroupsController, feature_category: :groups_and_projects d
describe 'POST #create' do
it 'creates group' do
expect do
- post :create, params: { group: { path: 'test', name: 'test' } }
+ post :create, params: { group: { path: 'test', name: 'test' } }
end.to change { Group.count }.by(1)
end
it 'creates namespace_settings for group' do
expect do
- post :create, params: { group: { path: 'test', name: 'test' } }
+ post :create, params: { group: { path: 'test', name: 'test' } }
end.to change { NamespaceSetting.count }.by(1)
end
it 'creates admin_note for group' do
expect do
- post :create, params: { group: { path: 'test', name: 'test', admin_note_attributes: { note: 'test' } } }
+ post :create, params: { group: { path: 'test', name: 'test', admin_note_attributes: { note: 'test' } } }
end.to change { Namespace::AdminNote.count }.by(1)
end
diff --git a/spec/frontend/admin/groups/components/filtered_search_and_sort_spec.js b/spec/frontend/admin/groups/components/filtered_search_and_sort_spec.js
new file mode 100644
index 00000000000..c3e7ff13b8e
--- /dev/null
+++ b/spec/frontend/admin/groups/components/filtered_search_and_sort_spec.js
@@ -0,0 +1,125 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import AdminGroupsFilteredSearchAndSort from '~/admin/groups/components/filtered_search_and_sort.vue';
+import FilteredSearchAndSort from '~/groups_projects/components/filtered_search_and_sort.vue';
+import {
+ FILTERED_SEARCH_TERM_KEY,
+ SORT_DIRECTION_ASC,
+ SORT_DIRECTION_DESC,
+ SORT_OPTION_CREATED_DATE,
+ SORT_OPTION_UPDATED_DATE,
+ SORT_OPTIONS,
+} from '~/admin/groups/constants';
+import { visitUrl } from '~/lib/utils/url_utility';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
+
+describe('AdminGroupsFilteredSearchAndSort', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(AdminGroupsFilteredSearchAndSort, {});
+ };
+
+ const findFilteredSearchAndSort = () => wrapper.findComponent(FilteredSearchAndSort);
+
+ it('renders FilteredSearchAndSort component with the correct initial props', () => {
+ createComponent();
+
+ expect(findFilteredSearchAndSort().props()).toMatchObject({
+ filteredSearchNamespace: 'admin-groups',
+ filteredSearchTokens: [],
+ filteredSearchTermKey: 'name',
+ filteredSearchRecentSearchesStorageKey: 'groups',
+ isAscending: false,
+ sortOptions: SORT_OPTIONS,
+ activeSortOption: SORT_OPTION_CREATED_DATE,
+ filteredSearchQuery: {},
+ });
+ });
+
+ describe('when the search bar is submitted', () => {
+ const searchTerm = 'test';
+
+ beforeEach(() => {
+ createComponent();
+
+ findFilteredSearchAndSort().vm.$emit('filter', {
+ [FILTERED_SEARCH_TERM_KEY]: searchTerm,
+ });
+ });
+
+ it('visits the URL with the correct query string', () => {
+ expect(visitUrl).toHaveBeenCalledWith(`?${FILTERED_SEARCH_TERM_KEY}=${searchTerm}`);
+ });
+ });
+
+ describe('when the sort item is changed', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findFilteredSearchAndSort().vm.$emit('sort-by-change', SORT_OPTION_UPDATED_DATE.value);
+ });
+
+ it('visits the URL with the correct query string', () => {
+ expect(visitUrl).toHaveBeenCalledWith(
+ `?sort=${SORT_OPTION_UPDATED_DATE.value}_${SORT_DIRECTION_DESC}`,
+ );
+ });
+ });
+
+ describe('when the sort direction is changed', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findFilteredSearchAndSort().vm.$emit('sort-direction-change', true);
+ });
+
+ it('visits the URL with the correct query string', () => {
+ expect(visitUrl).toHaveBeenCalledWith(
+ `?sort=${SORT_OPTION_CREATED_DATE.value}_${SORT_DIRECTION_ASC}`,
+ );
+ });
+ });
+
+ describe('when the search term is present and the sort item is changed', () => {
+ const searchTerm = 'group-name';
+
+ beforeEach(() => {
+ setWindowLocation(`?${FILTERED_SEARCH_TERM_KEY}=${searchTerm}`);
+
+ createComponent();
+
+ findFilteredSearchAndSort().vm.$emit('sort-direction-change', true);
+ });
+
+ it('visits the URL with the correct query string', () => {
+ expect(visitUrl).toHaveBeenCalledWith(
+ `?${FILTERED_SEARCH_TERM_KEY}=${searchTerm}&sort=${SORT_OPTION_CREATED_DATE.value}_${SORT_DIRECTION_ASC}`,
+ );
+ });
+ });
+
+ describe('when the sort item is present and the search term is changed', () => {
+ const searchTerm = 'group-name';
+
+ beforeEach(() => {
+ setWindowLocation(`?sort=${SORT_OPTION_CREATED_DATE.value}_${SORT_DIRECTION_ASC}`);
+
+ createComponent();
+
+ findFilteredSearchAndSort().vm.$emit('filter', {
+ [FILTERED_SEARCH_TERM_KEY]: searchTerm,
+ });
+ });
+
+ it('visits the URL with the correct query string', () => {
+ expect(visitUrl).toHaveBeenCalledWith(
+ `?${FILTERED_SEARCH_TERM_KEY}=${searchTerm}&sort=${SORT_OPTION_CREATED_DATE.value}_${SORT_DIRECTION_ASC}`,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/merge_request_dashboard/components/__snapshots__/collapsible_section_spec.js.snap b/spec/frontend/merge_request_dashboard/components/__snapshots__/collapsible_section_spec.js.snap
index 5b054fca75d..7e034979b18 100644
--- a/spec/frontend/merge_request_dashboard/components/__snapshots__/collapsible_section_spec.js.snap
+++ b/spec/frontend/merge_request_dashboard/components/__snapshots__/collapsible_section_spec.js.snap
@@ -24,6 +24,7 @@ exports[`Merge request dashboard collapsible section renders section 1`] = `
Approved
{
expect(wrapper.findByTestId('section-content').exists()).toBe(false);
});
+ it('hides badge when count is null', () => {
+ createComponent(null);
+
+ expect(wrapper.findByTestId('merge-request-list-count').exists()).toBe(false);
+ });
+
it('expands collapsed content', async () => {
createComponent(1);
diff --git a/spec/frontend/merge_request_dashboard/components/merge_requests_query_spec.js b/spec/frontend/merge_request_dashboard/components/merge_requests_query_spec.js
index 7a0daba767b..4d3633e7964 100644
--- a/spec/frontend/merge_request_dashboard/components/merge_requests_query_spec.js
+++ b/spec/frontend/merge_request_dashboard/components/merge_requests_query_spec.js
@@ -16,6 +16,7 @@ describe('Merge requests query component', () => {
let slotSpy;
let reviewerQueryMock;
let assigneeQueryMock;
+ let assigneeCountQueryMock;
function createComponent(
props = { query: 'reviewRequestedMergeRequests', variables: { state: 'opened' } },
@@ -54,6 +55,9 @@ describe('Merge requests query component', () => {
},
},
});
+ assigneeCountQueryMock = jest
+ .fn()
+ .mockResolvedValue({ data: { currentUser: { id: 1, mergeRequests: { count: 1 } } } });
const apolloProvider = createMockApollo(
[
[reviewerQuery, reviewerQueryMock],
@@ -64,12 +68,7 @@ describe('Merge requests query component', () => {
.fn()
.mockResolvedValue({ data: { currentUser: { id: 1, mergeRequests: { count: 1 } } } }),
],
- [
- assigneeCountQuery,
- jest
- .fn()
- .mockResolvedValue({ data: { currentUser: { id: 1, mergeRequests: { count: 1 } } } }),
- ],
+ [assigneeCountQuery, assigneeCountQueryMock],
],
{},
{ typePolicies: { Query: { fields: { currentUser: { merge: false } } } } },
@@ -112,6 +111,18 @@ describe('Merge requests query component', () => {
});
});
+ it('does not call count query if hideCount is true', async () => {
+ createComponent({
+ query: 'assignedMergeRequests',
+ variables: { state: 'opened' },
+ hideCount: true,
+ });
+
+ await waitForPromises();
+
+ expect(assigneeCountQueryMock).not.toHaveBeenCalled();
+ });
+
it.each([
['reviewRequestedMergeRequests', 'reviewer'],
['assignedMergeRequests', 'assignee'],
diff --git a/spec/frontend/merge_request_dashboard/components/tab_title_spec.js b/spec/frontend/merge_request_dashboard/components/tab_title_spec.js
new file mode 100644
index 00000000000..943878f1eec
--- /dev/null
+++ b/spec/frontend/merge_request_dashboard/components/tab_title_spec.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import TabTitle from '~/merge_request_dashboard/components/tab_title.vue';
+import reviewerCountQuery from '~/merge_request_dashboard/queries/reviewer_count.query.graphql';
+import assigneeCountQuery from '~/merge_request_dashboard/queries/assignee_count.query.graphql';
+
+Vue.use(VueApollo);
+
+describe('Merge requests tab title component', () => {
+ let reviewerCountQueryMock;
+ let assigneeCountQueryMock;
+ let wrapper;
+
+ function createComponent(props = { queries: [] }) {
+ reviewerCountQueryMock = jest.fn().mockResolvedValue({
+ data: { currentUser: { id: 1, mergeRequests: { count: 1 } } },
+ });
+ assigneeCountQueryMock = jest
+ .fn()
+ .mockResolvedValue({ data: { currentUser: { id: 1, mergeRequests: { count: 1 } } } });
+ const apolloProvider = createMockApollo(
+ [
+ [reviewerCountQuery, reviewerCountQueryMock],
+ [assigneeCountQuery, assigneeCountQueryMock],
+ ],
+ {},
+ { typePolicies: { Query: { fields: { currentUser: { merge: false } } } } },
+ );
+
+ wrapper = shallowMountExtended(TabTitle, {
+ apolloProvider,
+ propsData: {
+ title: 'All',
+ tabKey: 'all',
+ ...props,
+ },
+ });
+ }
+
+ const findTabCount = () => wrapper.findByTestId('tab-count');
+
+ it.each`
+ queries | count
+ ${['reviewRequestedMergeRequests']} | ${'1'}
+ ${['reviewRequestedMergeRequests', 'assignedMergeRequests']} | ${'2'}
+ `('sets count as $count for queries $queries', async ({ count, queries }) => {
+ createComponent({ queries: queries.map((query) => ({ query })) });
+
+ await waitForPromises();
+
+ expect(findTabCount().text()).toBe(count);
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
index 701a68ebf19..e107fa92e38 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js
@@ -13,6 +13,7 @@ import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item
import WorkItemActionsSplitButton from '~/work_items/components/work_item_links/work_item_actions_split_button.vue';
import WorkItemMoreActions from '~/work_items/components/shared/work_item_more_actions.vue';
import WorkItemRolledUpData from '~/work_items/components/work_item_links/work_item_rolled_up_data.vue';
+import WorkItemRolledUpCount from '~/work_items/components/work_item_links/work_item_rolled_up_count.vue';
import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql';
import namespaceWorkItemTypesQuery from '~/work_items/graphql/namespace_work_item_types.query.graphql';
import {
@@ -58,6 +59,7 @@ describe('WorkItemTree', () => {
const findMoreActions = () => wrapper.findComponent(WorkItemMoreActions);
const findCrudComponent = () => wrapper.findComponent(CrudComponent);
const findRolledUpData = () => wrapper.findComponent(WorkItemRolledUpData);
+ const findRolledUpCount = () => wrapper.findComponent(WorkItemRolledUpCount);
const createComponent = async ({
workItemType = 'Objective',
@@ -360,18 +362,25 @@ describe('WorkItemTree', () => {
createComponent({ shouldWaitForPromise: false });
expect(findRolledUpData().exists()).toBe(false);
+ expect(findRolledUpCount().exists()).toBe(false);
await waitForPromises();
expect(findRolledUpData().exists()).toBe(true);
+ expect(findRolledUpCount().exists()).toBe(true);
expect(findRolledUpData().props()).toEqual({
workItemId: 'gid://gitlab/WorkItem/2',
workItemIid: '2',
workItemType: 'Objective',
- rolledUpCountsByType: mockRolledUpCountsByType,
fullPath: 'test/project',
});
+
+ expect(findRolledUpCount().props()).toEqual({
+ hideCountWhenZero: false,
+ infoType: 'badge',
+ rolledUpCountsByType: mockRolledUpCountsByType,
+ });
});
it('fetches widget definitions and passes formatted allowed children by type to children wrapper', async () => {
diff --git a/spec/helpers/sorting_helper_spec.rb b/spec/helpers/sorting_helper_spec.rb
index dccea889d55..a0ae1d1ca5f 100644
--- a/spec/helpers/sorting_helper_spec.rb
+++ b/spec/helpers/sorting_helper_spec.rb
@@ -142,23 +142,6 @@ RSpec.describe SortingHelper, feature_category: :shared do
end
end
- describe '#groups_sort_options_hash' do
- let(:expected_options) do
- {
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_recently_created => sort_title_recently_created,
- sort_value_oldest_created => sort_title_oldest_created,
- sort_value_latest_activity => sort_title_recently_updated,
- sort_value_oldest_activity => sort_title_oldest_updated
- }
- end
-
- it 'returns a hash of available sorting options for the groups' do
- expect(groups_sort_options_hash).to eq(expected_options)
- end
- end
-
describe 'with `projects` controller' do
before do
stub_controller_path 'projects'
diff --git a/spec/views/shared/groups/_dropdown.html.haml_spec.rb b/spec/views/shared/groups/_dropdown.html.haml_spec.rb
deleted file mode 100644
index 8a09c0de383..00000000000
--- a/spec/views/shared/groups/_dropdown.html.haml_spec.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'shared/groups/_dropdown.html.haml' do
- describe 'render' do
- describe 'when a sort option is not selected' do
- before do
- render 'shared/groups/dropdown'
- end
-
- it 'renders a default sort option' do
- expect(rendered).to have_content 'Last created'
- end
-
- it 'renders correct sort by options' do
- html_rendered = Nokogiri::HTML(rendered)
- sort_options = Gitlab::Json.parse(html_rendered.css('[data-items]')[0]['data-items'])
-
- expect(sort_options.size).to eq(6)
- expect(sort_options[0]['value']).to eq('name_asc')
- expect(sort_options[0]['text']).to eq(s_('SortOptions|Name'))
-
- expect(sort_options[1]['value']).to eq('name_desc')
- expect(sort_options[1]['text']).to eq(s_('SortOptions|Name, descending'))
-
- expect(sort_options[2]['value']).to eq('created_desc')
- expect(sort_options[2]['text']).to eq(s_('SortOptions|Last created'))
-
- expect(sort_options[3]['value']).to eq('created_asc')
- expect(sort_options[3]['text']).to eq(s_('SortOptions|Oldest created'))
-
- expect(sort_options[4]['value']).to eq('latest_activity_desc')
- expect(sort_options[4]['text']).to eq(_('Updated date'))
-
- expect(sort_options[5]['value']).to eq('latest_activity_asc')
- expect(sort_options[5]['text']).to eq(s_('SortOptions|Oldest updated'))
- end
- end
-
- describe 'when a sort option is selected' do
- before do
- assign(:sort, 'name_desc')
-
- render 'shared/groups/dropdown'
- end
-
- it 'renders the selected sort option' do
- expect(rendered).to have_content 'Name, descending'
- end
- end
- end
-end