summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-01-11 06:10:58 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-01-11 06:10:58 +0000
commitf31ef3fd5548f9ffd5740b6aaca8672c27e34b42 (patch)
tree9f270beb0c4cad85b2a50eb25eb6712966cd1ddf
parentf60515eae2fc00c56742462826ae00826eb8826e (diff)
downloadgitlab-ce-f31ef3fd5548f9ffd5740b6aaca8672c27e34b42.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js2
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue (renamed from app/assets/javascripts/issues_list/components/issue_card_time_info.vue)0
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue (renamed from app/assets/javascripts/issues_list/components/issues_list_app.vue)10
-rw-r--r--app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue (renamed from app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue)0
-rw-r--r--app/assets/javascripts/issues/list/components/new_issue_dropdown.vue (renamed from app/assets/javascripts/issues_list/components/new_issue_dropdown.vue)2
-rw-r--r--app/assets/javascripts/issues/list/constants.js (renamed from app/assets/javascripts/issues_list/constants.js)0
-rw-r--r--app/assets/javascripts/issues/list/eventhub.js (renamed from app/assets/javascripts/issues_list/eventhub.js)0
-rw-r--r--app/assets/javascripts/issues/list/index.js (renamed from app/assets/javascripts/issues_list/index.js)4
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql (renamed from app/assets/javascripts/issues_list/queries/get_issues.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql (renamed from app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql (renamed from app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql (renamed from app/assets/javascripts/issues_list/queries/issue.fragment.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/label.fragment.graphql (renamed from app/assets/javascripts/issues_list/queries/label.fragment.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/milestone.fragment.graphql (renamed from app/assets/javascripts/issues_list/queries/milestone.fragment.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql (renamed from app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/search_labels.query.graphql (renamed from app/assets/javascripts/issues_list/queries/search_labels.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/search_milestones.query.graphql (renamed from app/assets/javascripts/issues_list/queries/search_milestones.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/search_projects.query.graphql (renamed from app/assets/javascripts/issues_list/queries/search_projects.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/search_users.query.graphql (renamed from app/assets/javascripts/issues_list/queries/search_users.query.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/queries/user.fragment.graphql (renamed from app/assets/javascripts/issues_list/queries/user.fragment.graphql)0
-rw-r--r--app/assets/javascripts/issues/list/utils.js (renamed from app/assets/javascripts/issues_list/utils.js)2
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue45
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js10
-rw-r--r--app/models/hooks/project_hook.rb5
-rw-r--r--app/models/hooks/service_hook.rb4
-rw-r--r--app/models/hooks/web_hook.rb5
-rw-r--r--app/services/web_hook_service.rb41
-rw-r--r--app/workers/web_hook_worker.rb12
-rw-r--r--config/feature_flags/development/webhook_recursion_detection.yml8
-rw-r--r--config/initializers/webhook_recursion_detection.rb5
-rw-r--r--doc/administration/operations/moving_repositories.md50
-rw-r--r--doc/user/application_security/dast/index.md2
-rw-r--r--doc/user/project/members/index.md2
-rw-r--r--doc/user/project/members/share_project_with_groups.md2
-rw-r--r--lib/api/ci/triggers.rb2
-rw-r--r--lib/backup/gitaly_backup.rb47
-rw-r--r--lib/backup/gitaly_rpc_backup.rb2
-rw-r--r--lib/backup/repositories.rb4
-rw-r--r--lib/gitlab/middleware/webhook_recursion_detection.rb19
-rw-r--r--lib/gitlab/web_hooks.rb7
-rw-r--r--lib/gitlab/web_hooks/recursion_detection.rb102
-rw-r--r--lib/gitlab/web_hooks/recursion_detection/uuid.rb46
-rw-r--r--lib/tasks/gitlab/backup.rake2
-rw-r--r--lib/tasks/gitlab/gitaly.rake38
-rw-r--r--locale/gitlab.pot6
-rwxr-xr-xscripts/gitaly-test-build2
-rwxr-xr-xscripts/gitaly-test-spawn13
-rw-r--r--spec/frontend/issues/list/components/issue_card_time_info_spec.js (renamed from spec/frontend/issues_list/components/issue_card_time_info_spec.js)2
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js (renamed from spec/frontend/issues_list/components/issues_list_app_spec.js)16
-rw-r--r--spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js (renamed from spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js)2
-rw-r--r--spec/frontend/issues/list/components/new_issue_dropdown_spec.js (renamed from spec/frontend/issues_list/components/new_issue_dropdown_spec.js)4
-rw-r--r--spec/frontend/issues/list/mock_data.js (renamed from spec/frontend/issues_list/mock_data.js)0
-rw-r--r--spec/frontend/issues/list/utils_spec.js (renamed from spec/frontend/issues_list/utils_spec.js)6
-rw-r--r--spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js29
-rw-r--r--spec/lib/backup/gitaly_backup_spec.rb32
-rw-r--r--spec/lib/backup/gitaly_rpc_backup_spec.rb10
-rw-r--r--spec/lib/backup/repositories_spec.rb12
-rw-r--r--spec/lib/gitlab/middleware/webhook_recursion_detection_spec.rb42
-rw-r--r--spec/lib/gitlab/web_hooks/recursion_detection_spec.rb243
-rw-r--r--spec/models/hooks/project_hook_spec.rb9
-rw-r--r--spec/models/hooks/service_hook_spec.rb30
-rw-r--r--spec/models/integrations/datadog_spec.rb4
-rw-r--r--spec/requests/api/ci/triggers_spec.rb2
-rw-r--r--spec/requests/recursive_webhook_detection_spec.rb200
-rw-r--r--spec/services/web_hook_service_spec.rb139
-rw-r--r--spec/support/helpers/gitaly_setup.rb131
-rw-r--r--spec/support/helpers/test_env.rb117
-rw-r--r--spec/support/praefect.rb4
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb2
-rw-r--r--spec/workers/web_hook_worker_spec.rb9
84 files changed, 1292 insertions, 364 deletions
diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
index 42ac7d4ef9f..d46354e240a 100644
--- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery';
-import issuableEventHub from '~/issues_list/eventhub';
+import issuableEventHub from '~/issues/list/eventhub';
import LabelsSelect from '~/labels/labels_select';
import MilestoneSelect from '~/milestones/milestone_select';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
index aece7372182..aece7372182 100644
--- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue
index b1a97a39869..8b15e801f02 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -11,9 +11,9 @@ import {
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { orderBy } from 'lodash';
-import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
-import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
-import IssueCardTimeInfo from 'ee_else_ce/issues_list/components/issue_card_time_info.vue';
+import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
+import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import createFlash, { FLASH_TYPES } from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -41,7 +41,7 @@ import {
TOKEN_TYPE_TYPE,
UPDATED_DESC,
urlSortParams,
-} from '~/issues_list/constants';
+} from '~/issues/list/constants';
import {
convertToApiParams,
convertToSearchQuery,
@@ -51,7 +51,7 @@ import {
getInitialPageParams,
getSortKey,
getSortOptions,
-} from '~/issues_list/utils';
+} from '~/issues/list/utils';
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue b/app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue
index fb1dbef666c..fb1dbef666c 100644
--- a/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue
+++ b/app/assets/javascripts/issues/list/components/jira_issues_import_status_app.vue
diff --git a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
index e749579af80..71f84050ba8 100644
--- a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
+++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
@@ -7,7 +7,7 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import createFlash from '~/flash';
-import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
+import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 4a380848b4f..4a380848b4f 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
diff --git a/app/assets/javascripts/issues_list/eventhub.js b/app/assets/javascripts/issues/list/eventhub.js
index e31806ad199..e31806ad199 100644
--- a/app/assets/javascripts/issues_list/eventhub.js
+++ b/app/assets/javascripts/issues/list/eventhub.js
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues/list/index.js
index 30c227619e0..01cc82ed8fd 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -1,8 +1,8 @@
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
-import IssuesListApp from 'ee_else_ce/issues_list/components/issues_list_app.vue';
+import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
index be8deb3fe97..be8deb3fe97 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
index 1a345fd2877..1a345fd2877 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql
index a53dba8c7c8..a53dba8c7c8 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues_list_details.query.graphql
diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 07dae3fd756..07dae3fd756 100644
--- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
diff --git a/app/assets/javascripts/issues_list/queries/label.fragment.graphql b/app/assets/javascripts/issues/list/queries/label.fragment.graphql
index bb1d8f1ac9b..bb1d8f1ac9b 100644
--- a/app/assets/javascripts/issues_list/queries/label.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/label.fragment.graphql
diff --git a/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql b/app/assets/javascripts/issues/list/queries/milestone.fragment.graphql
index 3cdf69bf585..3cdf69bf585 100644
--- a/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/milestone.fragment.graphql
diff --git a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql b/app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql
index 160026a4742..160026a4742 100644
--- a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql
+++ b/app/assets/javascripts/issues/list/queries/reorder_issues.mutation.graphql
diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql
index 44b57317161..44b57317161 100644
--- a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql
diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
index e7eb08104a6..e7eb08104a6 100644
--- a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_milestones.query.graphql
diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql
index bd2f9bc2340..bd2f9bc2340 100644
--- a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql
diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
index 92517ad35d0..92517ad35d0 100644
--- a/app/assets/javascripts/issues_list/queries/search_users.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_users.query.graphql
diff --git a/app/assets/javascripts/issues_list/queries/user.fragment.graphql b/app/assets/javascripts/issues/list/queries/user.fragment.graphql
index 3e5bc0f7b93..3e5bc0f7b93 100644
--- a/app/assets/javascripts/issues_list/queries/user.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/user.fragment.graphql
diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues/list/utils.js
index 99946e4e851..c64925edc28 100644
--- a/app/assets/javascripts/issues_list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -36,7 +36,7 @@ import {
urlSortParams,
WEIGHT_ASC,
WEIGHT_DESC,
-} from '~/issues_list/constants';
+} from '~/issues/list/constants';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
import {
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index b68e52d8ab1..725c38defc3 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,6 +1,6 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar';
-import { mountIssuesListApp } from '~/issues_list';
+import { mountIssuesListApp } from '~/issues/list';
import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index c8fdcfe0502..44b1d5277d1 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -2,7 +2,7 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar';
-import { mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list';
+import { mountIssuesListApp, mountJiraIssuesListApp } from '~/issues/list';
import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import { ISSUABLE_INDEX } from '~/issuable/constants';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 677c50ed930..2e3a02b1712 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ciIcon from '../../vue_shared/components/ci_icon.vue';
export default {
@@ -8,6 +9,7 @@ export default {
GlButton,
GlLoadingIcon,
},
+ mixins: [glFeatureFlagMixin()],
props: {
status: {
type: String,
@@ -42,7 +44,7 @@ export default {
</div>
<gl-button
- v-if="showDisabledButton"
+ v-if="!glFeatures.restructuredMrWidget && showDisabledButton"
category="primary"
variant="success"
data-testid="disabled-merge-button"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index ce572f8b0bf..574ff8b3b2f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -1,20 +1,16 @@
<script>
-import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
-import notesEventHub from '~/notes/event_hub';
import StatusIcon from '../mr_widget_status_icon.vue';
export default {
i18n: {
- pipelineFailed: s__(
- 'mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure.',
- ),
approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'),
- unresolvedDiscussions: s__('mrWidget|Merge blocked: all threads must be resolved.'),
+ blockingMergeRequests: s__(
+ 'mrWidget|Merge blocked: you can only merge once above items are resolved.',
+ ),
},
components: {
StatusIcon,
- GlButton,
},
props: {
mr: {
@@ -24,22 +20,15 @@ export default {
},
computed: {
failedText() {
- if (this.mr.isPipelineFailed) {
- return this.$options.i18n.pipelineFailed;
- } else if (this.mr.approvals && !this.mr.isApproved) {
+ if (this.mr.approvals && !this.mr.isApproved) {
return this.$options.i18n.approvalNeeded;
- } else if (this.mr.hasMergeableDiscussionsState) {
- return this.$options.i18n.unresolvedDiscussions;
+ } else if (this.mr.blockingMergeRequests?.total_count > 0) {
+ return this.$options.i18n.blockingMergeRequests;
}
return null;
},
},
- methods: {
- jumpToFirstUnresolvedDiscussion() {
- notesEventHub.$emit('jumpToFirstUnresolvedDiscussion');
- },
- },
};
</script>
@@ -48,28 +37,6 @@ export default {
<status-icon status="warning" />
<p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!">
{{ failedText }}
- <template v-if="failedText == $options.i18n.unresolvedDiscussions">
- <gl-button
- class="gl-ml-3"
- size="small"
- variant="confirm"
- data-testid="jumpToUnresolved"
- @click="jumpToFirstUnresolvedDiscussion"
- >
- {{ s__('mrWidget|Jump to first unresolved thread') }}
- </gl-button>
- <gl-button
- v-if="mr.createIssueToResolveDiscussionsPath"
- :href="mr.createIssueToResolveDiscussionsPath"
- class="gl-ml-3"
- size="small"
- variant="confirm"
- category="secondary"
- data-testid="resolveIssue"
- >
- {{ s__('mrWidget|Create issue to resolve all threads') }}
- </gl-button>
- </template>
</p>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 13b1e49f44e..071920856a8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -1,25 +1,22 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetArchived',
components: {
- GlButton,
statusIcon,
},
+ mixins: [glFeatureFlagMixin()],
};
</script>
<template>
<div class="mr-widget-body media">
<div class="space-children">
- <status-icon status="warning" />
- <gl-button category="secondary" variant="success" :disabled="true">
- {{ s__('mrWidget|Merge') }}
- </gl-button>
+ <status-icon status="warning" show-disabled-button />
</div>
<div class="media-body">
- <span class="bold">
+ <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
{{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index 10b93d7849f..fd42fa0421f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -12,9 +12,11 @@ export default {
</script>
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="!glFeatures.restructuredMrWidget" status="loading" />
+ <status-icon :show-disabled-button="true" status="loading" />
<div class="media-body space-children">
- <span class="bold"> {{ s__('mrWidget|Checking if merge request can be merged…') }} </span>
+ <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ {{ s__('mrWidget|Checking if merge request can be merged…') }}
+ </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 7a002d41ac0..a2c9cfe53cc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -109,14 +109,18 @@ export default {
</gl-skeleton-loader>
</div>
<div v-else class="media-body space-children gl-display-flex gl-align-items-center">
- <span v-if="shouldBeRebased" class="bold">
+ <span
+ v-if="shouldBeRebased"
+ :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
+ class="bold"
+ >
{{
s__(`mrWidget|Merge blocked: fast-forward merge is not possible.
To merge this request, first rebase locally.`)
}}
</span>
<template v-else>
- <span class="bold">
+ <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
{{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }}
<span v-if="!canMerge">
{{
@@ -129,6 +133,7 @@ export default {
<gl-button
v-if="showResolveButton"
:href="mr.conflictResolutionPath"
+ :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
data-testid="resolve-conflicts-button"
>
{{ s__('mrWidget|Resolve conflicts') }}
@@ -136,6 +141,7 @@ export default {
<gl-button
v-if="canMerge"
v-gl-modal-directive="'modal-merge-info'"
+ :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
data-testid="merge-locally-button"
>
{{ s__('mrWidget|Merge locally') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index f91350d4a82..5b03eda2eac 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -74,10 +74,21 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span class="bold js-branch-text">
+ <span
+ :class="{
+ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
+ }"
+ class="bold js-branch-text"
+ >
<span class="capitalize" data-testid="missingBranchName"> {{ missingBranchName }} </span>
{{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }}
- <gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" />
+ <gl-icon
+ v-gl-tooltip
+ :title="message"
+ :aria-label="message"
+ name="question-o"
+ class="gl-text-blue-600 gl-cursor-pointer"
+ />
</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
index 68ffca9cd68..34c5a2ff2c8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
@@ -1,4 +1,5 @@
<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import StatusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -6,13 +7,14 @@ export default {
components: {
StatusIcon,
},
+ mixins: [glFeatureFlagMixin()],
};
</script>
<template>
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span class="bold">
+ <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
{{
s__(
`mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 01e8303f513..886fab8634c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -152,12 +152,14 @@ export default {
<div class="rebase-state-find-class-convention media media-body space-children">
<span
v-if="rebaseInProgress || isMakingRequest"
+ :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
class="gl-font-weight-bold"
data-testid="rebase-message"
>{{ __('Rebase in progress') }}</span
>
<span
v-if="!rebaseInProgress && !canPushToSourceBranch"
+ :class="{ 'gl-text-body!': glFeatures.restructuredMrWidget }"
class="gl-font-weight-bold gl-ml-0!"
data-testid="rebase-message"
>{{ fastForwardMergeText }}</span
@@ -167,6 +169,7 @@ export default {
class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
>
<gl-button
+ v-if="!glFeatures.restructuredMrWidget"
:loading="isMakingRequest"
variant="confirm"
data-qa-selector="mr_rebase_button"
@@ -176,6 +179,7 @@ export default {
</gl-button>
<span
v-if="!rebasingError"
+ :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
class="gl-font-weight-bold"
data-testid="rebase-message"
data-qa-selector="no_fast_forward_message_content"
@@ -186,6 +190,17 @@ export default {
<span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{
rebasingError
}}</span>
+ <gl-button
+ v-if="glFeatures.restructuredMrWidget"
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ data-qa-selector="mr_rebase_button"
+ class="gl-ml-3!"
+ @click="rebase"
+ >
+ {{ __('Rebase') }}
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
index b5d2f91c637..d88dad2e086 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -1,6 +1,7 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__ } from '~/locale';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -11,6 +12,7 @@ export default {
GlSprintf,
statusIcon,
},
+ mixins: [glFeatureFlagMixin()],
computed: {
troubleshootingDocsPath() {
return helpPagePath('ci/troubleshooting', { anchor: 'merge-request-status-messages' });
@@ -28,7 +30,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span class="bold">
+ <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
<gl-sprintf :message="$options.i18n.failedMessage">
<template #link="{ content }">
<gl-link :href="troubleshootingDocsPath" target="_blank">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 8830128b7d6..06ce312bd4c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -529,7 +529,7 @@ export default {
<template>
<div
:class="{
- 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7':
+ 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base':
glFeatures.restructuredMrWidget,
}"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index 7eeba8d8f89..b1fbe150fcf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { I18N_SHA_MISMATCH } from '../../i18n';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -12,6 +13,7 @@ export default {
i18n: {
I18N_SHA_MISMATCH,
},
+ mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -25,7 +27,11 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="false" status="warning" />
<div class="media-body">
- <span class="gl-font-weight-bold" data-qa-selector="head_mismatch_content">
+ <span
+ :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
+ class="gl-font-weight-bold"
+ data-qa-selector="head_mismatch_content"
+ >
{{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }}
</span>
<gl-button
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 69e4df0ca11..8cf6383c26a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -9,6 +10,7 @@ export default {
statusIcon,
GlButton,
},
+ mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -25,16 +27,24 @@ export default {
<template>
<div class="mr-widget-body media gl-flex-wrap">
- <status-icon :show-disabled-button="true" status="warning" />
+ <status-icon show-disabled-button status="warning" />
<div class="media-body">
- <span class="gl-ml-3 gl-font-weight-bold gl-display-block gl-w-100">{{
- s__('mrWidget|Merge blocked: all threads must be resolved.')
- }}</span>
+ <span
+ :class="{
+ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
+ 'gl-display-block': !glFeatures.restructuredMrWidget,
+ }"
+ class="gl-ml-3 gl-font-weight-bold gl-w-100"
+ >
+ {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }}
+ </span>
<gl-button
data-testid="jump-to-first"
class="gl-ml-3"
size="small"
- icon="comment-next"
+ :icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'"
+ :variant="glFeatures.restructuredMrWidget && 'confirm'"
+ :category="glFeatures.restructuredMrWidget && 'secondary'"
@click="jumpToFirstUnresolvedDiscussion"
>
{{ s__('mrWidget|Jump to first unresolved thread') }}
@@ -44,7 +54,7 @@ export default {
:href="mr.createIssueToResolveDiscussionsPath"
class="js-create-issue gl-ml-3"
size="small"
- icon="issue-new"
+ :icon="glFeatures.restructuredMrWidget ? undefined : 'issue-new'"
>
{{ s__('mrWidget|Create issue to resolve all threads') }}
</gl-button>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index ba831a33b73..e0e19094c40 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -166,7 +166,10 @@ export default {
<status-icon :show-disabled-button="canUpdate" status="warning" />
<div class="media-body">
<div class="float-left">
- <span class="gl-font-weight-bold">
+ <span
+ :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
+ class="gl-font-weight-bold"
+ >
{{
__("Merge blocked: merge request must be marked as ready. It's still marked as draft.")
}}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 57af869a0ba..d95982eb74c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -357,15 +357,13 @@ export default class MergeRequestStore {
setApprovals(data) {
this.approvals = data;
this.isApproved = data.approved || false;
+
+ this.setState();
}
+ // eslint-disable-next-line class-methods-use-this
get hasMergeChecksFailed() {
- if (!window.gon?.features?.restructuredMrWidget) return false;
-
- return (
- this.hasMergeableDiscussionsState ||
- (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed)
- );
+ return false;
}
// Because the state machine doesn't yet handle every state and transition,
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index 16b95d2a2b9..9f45160d3a8 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -41,6 +41,11 @@ class ProjectHook < WebHook
super.merge(project: project)
end
+ override :parent
+ def parent
+ project
+ end
+
private
override :web_hooks_disable_failed?
diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb
index 1a466b333a5..80e167b350b 100644
--- a/app/models/hooks/service_hook.rb
+++ b/app/models/hooks/service_hook.rb
@@ -2,6 +2,7 @@
class ServiceHook < WebHook
include Presentable
+ extend ::Gitlab::Utils::Override
belongs_to :integration, foreign_key: :service_id
validates :integration, presence: true
@@ -9,4 +10,7 @@ class ServiceHook < WebHook
def execute(data, hook_name = 'service_hook')
super(data, hook_name)
end
+
+ override :parent
+ delegate :parent, to: :integration
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index e8a55abfc8f..3320c13e87b 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -122,6 +122,11 @@ class WebHook < ApplicationRecord
nil
end
+ # Returns the associated Project or Group for the WebHook if one exists.
+ # Overridden by inheriting classes.
+ def parent
+ end
+
# Custom attributes to be included in the worker context.
def application_context
{ related_class: type }
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 79bdf34392f..33e34ec41e2 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -26,7 +26,6 @@ class WebHookService
end
REQUEST_BODY_SIZE_LIMIT = 25.megabytes
- GITLAB_EVENT_HEADER = 'X-Gitlab-Event'
attr_accessor :hook, :data, :hook_name, :request_options
attr_reader :uniqueness_token
@@ -50,6 +49,10 @@ class WebHookService
def execute
return { status: :error, message: 'Hook disabled' } unless hook.executable?
+ log_recursion_limit if recursion_blocked?
+
+ Gitlab::WebHooks::RecursionDetection.register!(hook)
+
start_time = Gitlab::Metrics::System.monotonic_time
response = if parsed_url.userinfo.blank?
@@ -95,6 +98,10 @@ class WebHookService
Gitlab::ApplicationContext.with_context(hook.application_context) do
break log_rate_limit if rate_limited?
+ log_recursion_limit if recursion_blocked?
+
+ data[:_gitlab_recursion_detection_request_uuid] = Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid
+
WebHookWorker.perform_async(hook.id, data, hook_name)
end
end
@@ -108,7 +115,7 @@ class WebHookService
def make_request(url, basic_auth = false)
Gitlab::HTTP.post(url,
body: Gitlab::Json::LimitedEncoder.encode(data, limit: REQUEST_BODY_SIZE_LIMIT),
- headers: build_headers(hook_name),
+ headers: build_headers,
verify: hook.enable_ssl_verification,
basic_auth: basic_auth,
**request_options)
@@ -129,7 +136,7 @@ class WebHookService
trigger: trigger,
url: url,
execution_duration: execution_duration,
- request_headers: build_headers(hook_name),
+ request_headers: build_headers,
request_data: request_data,
response_headers: format_response_headers(response),
response_body: safe_response_body(response),
@@ -151,15 +158,16 @@ class WebHookService
end
end
- def build_headers(hook_name)
+ def build_headers
@headers ||= begin
- {
+ headers = {
'Content-Type' => 'application/json',
'User-Agent' => "GitLab/#{Gitlab::VERSION}",
- GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name)
- }.tap do |hash|
- hash['X-Gitlab-Token'] = Gitlab::Utils.remove_line_breaks(hook.token) if hook.token.present?
- end
+ Gitlab::WebHooks::GITLAB_EVENT_HEADER => self.class.hook_to_event(hook_name)
+ }
+
+ headers['X-Gitlab-Token'] = Gitlab::Utils.remove_line_breaks(hook.token) if hook.token.present?
+ headers.merge!(Gitlab::WebHooks::RecursionDetection.header(hook))
end
end
@@ -186,6 +194,10 @@ class WebHookService
)
end
+ def recursion_blocked?
+ Gitlab::WebHooks::RecursionDetection.block?(hook)
+ end
+
def rate_limit
@rate_limit ||= hook.rate_limit
end
@@ -199,4 +211,15 @@ class WebHookService
**Gitlab::ApplicationContext.current
)
end
+
+ def log_recursion_limit
+ Gitlab::AuthLogger.error(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: hook.id,
+ hook_type: hook.type,
+ hook_name: hook_name,
+ recursion_detection: ::Gitlab::WebHooks::RecursionDetection.to_log(hook),
+ **Gitlab::ApplicationContext.current
+ )
+ end
end
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index 5b4567dde29..952ac94d5e6 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -13,11 +13,21 @@ class WebHookWorker
worker_has_external_dependencies!
- def perform(hook_id, data, hook_name)
+ # Webhook recursion detection properties are passed through the `data` arg.
+ # This will be migrated to the `params` arg over the next few releases.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/347389.
+ def perform(hook_id, data, hook_name, params = {})
hook = WebHook.find_by_id(hook_id)
return unless hook
data = data.with_indifferent_access
+
+ # Before executing the hook, reapply any recursion detection UUID that was
+ # initially present in the request header so the hook can pass this same header
+ # value in its request.
+ recursion_detection_uuid = data.delete(:_gitlab_recursion_detection_request_uuid)
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(recursion_detection_uuid)
+
WebHookService.new(hook, data, hook_name, jid).execute
end
end
diff --git a/config/feature_flags/development/webhook_recursion_detection.yml b/config/feature_flags/development/webhook_recursion_detection.yml
new file mode 100644
index 00000000000..8ef0bf81491
--- /dev/null
+++ b/config/feature_flags/development/webhook_recursion_detection.yml
@@ -0,0 +1,8 @@
+---
+name: webhook_recursion_detection
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75821
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/349845
+milestone: '14.7'
+type: development
+group: group::integrations
+default_enabled: false
diff --git a/config/initializers/webhook_recursion_detection.rb b/config/initializers/webhook_recursion_detection.rb
new file mode 100644
index 00000000000..b345c005bac
--- /dev/null
+++ b/config/initializers/webhook_recursion_detection.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+Rails.application.configure do |config|
+ config.middleware.insert_after RequestStore::Middleware, Gitlab::Middleware::WebhookRecursionDetection
+end
diff --git a/doc/administration/operations/moving_repositories.md b/doc/administration/operations/moving_repositories.md
index 84e6dca1f2b..f0eb5792a96 100644
--- a/doc/administration/operations/moving_repositories.md
+++ b/doc/administration/operations/moving_repositories.md
@@ -184,8 +184,8 @@ Each of the approaches we list can or does overwrite data in the target director
### Recommended approach in all cases
-The GitLab [backup and restore capability](../../raketasks/backup_restore.md) should be used. Git
-repositories are accessed, managed, and stored on GitLab servers by Gitaly as a database. Data loss
+For either Gitaly or Gitaly Cluster targets, the GitLab [backup and restore capability](../../raketasks/backup_restore.md)
+should be used. Git repositories are accessed, managed, and stored on GitLab servers by Gitaly as a database. Data loss
can result from directly accessing and copying Gitaly's files using tools like `rsync`.
- From GitLab 13.3, backup performance can be improved by
@@ -193,13 +193,15 @@ can result from directly accessing and copying Gitaly's files using tools like `
- Backups can be created of just the repositories using the
[skip feature](../../raketasks/backup_restore.md#excluding-specific-directories-from-the-backup).
+No other method works for Gitaly Cluster targets.
+
### Target directory is empty: use a `tar` pipe
-If the target directory `/mnt/gitlab/repositories` is empty the
-simplest thing to do is to use a `tar` pipe. This method has low
-overhead and `tar` is almost always already installed on your system.
-However, it is not possible to resume an interrupted `tar` pipe: if
-that happens then all data must be copied again.
+For Gitaly targets (use [recommended approach](#recommended-approach-in-all-cases) for Gitaly Cluster targets), if the
+target directory `/mnt/gitlab/repositories` is empty the simplest thing to do is to use a `tar` pipe. This method has
+low overhead and `tar` is almost always already installed on your system.
+
+However, it is not possible to resume an interrupted `tar` pipe; if that happens then all data must be copied again.
```shell
sudo -u git sh -c 'tar -C /var/opt/gitlab/git-data/repositories -cf - -- . |\
@@ -210,9 +212,9 @@ If you want to see progress, replace `-xf` with `-xvf`.
#### `tar` pipe to another server
-You can also use a `tar` pipe to copy data to another server. If your
-`git` user has SSH access to the new server as `git@newserver`, you
-can pipe the data through SSH.
+For Gitaly targets (use [recommended approach](#recommended-approach-in-all-cases) for Gitaly Cluster targets), you can
+also use a `tar` pipe to copy data to another server. If your `git` user has SSH access to the new server as
+`git@newserver`, you can pipe the data through SSH.
```shell
sudo -u git sh -c 'tar -C /var/opt/gitlab/git-data/repositories -cf - -- . |\
@@ -228,11 +230,11 @@ WARNING:
Using `rsync` to migrate Git data can cause data loss and repository corruption.
[These instructions are being reviewed](https://gitlab.com/gitlab-org/gitlab/-/issues/270422).
-If the target directory already contains a partial / outdated copy
-of the repositories it may be wasteful to copy all the data again
-with `tar`. In this scenario it is better to use `rsync`. This utility
-is either already installed on your system, or installable
-by using `apt` or `yum`.
+If the target directory already contains a partial or outdated copy of the repositories it may be wasteful to copy all
+the data again with `tar`. In this scenario it is better to use `rsync` for Gitaly targets (use
+[recommended approach](#recommended-approach-in-all-cases) for Gitaly Cluster targets).
+
+This utility is either already installed on your system, or installable using `apt` or `yum`.
```shell
sudo -u git sh -c 'rsync -a --delete /var/opt/gitlab/git-data/repositories/. \
@@ -249,8 +251,9 @@ WARNING:
Using `rsync` to migrate Git data can cause data loss and repository corruption.
[These instructions are being reviewed](https://gitlab.com/gitlab-org/gitlab/-/issues/270422).
-If the `git` user on your source system has SSH access to the target
-server you can send the repositories over the network with `rsync`.
+For Gitaly targets (use [recommended approach](#recommended-approach-in-all-cases) for Gitaly Cluster targets), if the
+`git` user on your source system has SSH access to the target server you can send the repositories over the network with
+`rsync`.
```shell
sudo -u git sh -c 'rsync -a --delete /var/opt/gitlab/git-data/repositories/. \
@@ -269,17 +272,18 @@ Every time you start an `rsync` job it must:
- Inspect all files in the target directory.
- Decide whether or not to copy files.
-If the source or target directory
-has many contents, this startup phase of `rsync` can become a burden
-for your GitLab server. You can reduce the workload of `rsync` by dividing its
-work in smaller pieces, and sync one repository at a time.
+If the source or target directory has many contents, this startup phase of `rsync` can become a burden for your GitLab
+server. You can reduce the workload of `rsync` by dividing its work into smaller pieces, and sync one repository at a
+time.
In addition to `rsync` we use [GNU Parallel](http://www.gnu.org/software/parallel/).
This utility is not included in GitLab, so you must install it yourself with `apt`
or `yum`.
-This process does not clean up repositories at the target location that no
-longer exist at the source.
+This process:
+
+- Doesn't clean up repositories at the target location that no longer exist at the source.
+- Only works for Gitaly targets. Use [recommended approach](#recommended-approach-in-all-cases) for Gitaly Cluster targets.
#### Parallel `rsync` for all repositories known to GitLab
diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md
index b65747969c9..3cf842f0de6 100644
--- a/doc/user/application_security/dast/index.md
+++ b/doc/user/application_security/dast/index.md
@@ -638,7 +638,7 @@ These CI/CD variables are specific to DAST. They can be used to customize the be
| `DAST_XML_REPORT` | string | The filename of the XML report written at the end of a scan. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12652) in GitLab 13.1. |
| `DAST_WEBSITE` <sup>1</sup> | URL | The URL of the website to scan. The variable `DAST_API_OPENAPI` must be specified if this is omitted. |
| `DAST_ZAP_CLI_OPTIONS` | string | ZAP server command-line options. For example, `-Xmx3072m` would set the Java maximum memory allocation pool size. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12652) in GitLab 13.1. |
-| `DAST_ZAP_LOG_CONFIGURATION` | string | Set to a semicolon-separated list of additional log4j properties for the ZAP Server. Example: `log4j.logger.org.parosproxy.paros.network.HttpSender=DEBUG;log4j.logger.com.crawljax=DEBUG` |
+| `DAST_ZAP_LOG_CONFIGURATION` | string | Set to a semicolon-separated list of additional log4j properties for the ZAP Server. Example: `logger.httpsender.name=org.parosproxy.paros.network.HttpSender;logger.httpsender.level=debug;logger.sitemap.name=org.parosproxy.paros.model.SiteMap;logger.sitemap.level=debug;` |
| `SECURE_ANALYZERS_PREFIX` | URL | Set the Docker registry base address from which to download the analyzer. |
1. Available to an on-demand DAST scan.
diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md
index 15ebd60f246..2dc29f5d725 100644
--- a/doc/user/project/members/index.md
+++ b/doc/user/project/members/index.md
@@ -211,7 +211,7 @@ Instead of adding users one by one, you can [share a project with an entire grou
> - Enabled on GitLab.com.
> - Recommended for production use.
> - Replaces the existing form with buttons to open a modal window.
-> - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-modal-window). **(FREE SELF)**
+> - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-modal-window).
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
diff --git a/doc/user/project/members/share_project_with_groups.md b/doc/user/project/members/share_project_with_groups.md
index 4c96c4d9f56..4e3bae2dc30 100644
--- a/doc/user/project/members/share_project_with_groups.md
+++ b/doc/user/project/members/share_project_with_groups.md
@@ -52,7 +52,7 @@ Administrators can share projects with any group in the system.
> - Enabled on GitLab.com.
> - Recommended for production use.
> - Replaces the existing form with buttons to open a modal window.
-> - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-modal-window). **(FREE SELF)**
+> - To use in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-modal-window).
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
diff --git a/lib/api/ci/triggers.rb b/lib/api/ci/triggers.rb
index 6a2b16e1568..ae89b475ef8 100644
--- a/lib/api/ci/triggers.rb
+++ b/lib/api/ci/triggers.rb
@@ -5,7 +5,7 @@ module API
class Triggers < ::API::Base
include PaginationParams
- HTTP_GITLAB_EVENT_HEADER = "HTTP_#{WebHookService::GITLAB_EVENT_HEADER}".underscore.upcase
+ HTTP_GITLAB_EVENT_HEADER = "HTTP_#{::Gitlab::WebHooks::GITLAB_EVENT_HEADER}".underscore.upcase
feature_category :continuous_integration
diff --git a/lib/backup/gitaly_backup.rb b/lib/backup/gitaly_backup.rb
index b104beed39c..8ac09e94004 100644
--- a/lib/backup/gitaly_backup.rb
+++ b/lib/backup/gitaly_backup.rb
@@ -2,11 +2,17 @@
module Backup
# 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
class GitalyBackup
- def initialize(progress, parallel: nil, parallel_storage: nil)
+ # @param [StringIO] progress IO interface to output progress
+ # @param [Integer] max_parallelism max parallelism when running backups
+ # @param [Integer] storage_parallelism max parallelism per storage (is affected by max_parallelism)
+ def initialize(progress, max_parallelism: nil, storage_parallelism: nil)
@progress = progress
- @parallel = parallel
- @parallel_storage = parallel_storage
+ @max_parallelism = max_parallelism
+ @storage_parallelism = storage_parallelism
end
def start(type)
@@ -22,20 +28,20 @@ module Backup
end
args = []
- args += ['-parallel', @parallel.to_s] if @parallel
- args += ['-parallel-storage', @parallel_storage.to_s] if @parallel_storage
+ args += ['-parallel', @max_parallelism.to_s] if @max_parallelism
+ args += ['-parallel-storage', @storage_parallelism.to_s] if @storage_parallelism
- @stdin, stdout, @thread = Open3.popen2(build_env, bin_path, command, '-path', backup_repos_path, *args)
+ @input_stream, stdout, @thread = Open3.popen2(build_env, bin_path, command, '-path', backup_repos_path, *args)
@out_reader = Thread.new do
IO.copy_stream(stdout, @progress)
end
end
- def wait
+ def finish!
return unless started?
- @stdin.close
+ @input_stream.close
[@thread, @out_reader].each(&:join)
status = @thread.value
@@ -49,12 +55,7 @@ module Backup
repository = repo_type.repository_for(container)
- @stdin.puts({
- storage_name: repository.storage,
- relative_path: repository.relative_path,
- gl_project_path: repository.gl_project_path,
- always_create: repo_type.project?
- }.merge(Gitlab::GitalyClient.connection_data(repository.storage)).to_json)
+ schedule_backup_job(repository, always_create: repo_type.project?)
end
def parallel_enqueue?
@@ -63,6 +64,24 @@ module Backup
private
+ # 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(repository, always_create:)
+ connection_params = Gitlab::GitalyClient.connection_data(repository.storage)
+
+ json_job = {
+ address: connection_params['address'],
+ token: connection_params['token'],
+ storage_name: repository.storage,
+ relative_path: repository.relative_path,
+ gl_project_path: repository.gl_project_path,
+ always_create: always_create
+ }.to_json
+
+ @input_stream.puts(json_job)
+ end
+
def build_env
{
'SSL_CERT_FILE' => OpenSSL::X509::DEFAULT_CERT_FILE,
diff --git a/lib/backup/gitaly_rpc_backup.rb b/lib/backup/gitaly_rpc_backup.rb
index baac4eb26ca..bbd83cd2157 100644
--- a/lib/backup/gitaly_rpc_backup.rb
+++ b/lib/backup/gitaly_rpc_backup.rb
@@ -23,7 +23,7 @@ module Backup
end
end
- def wait
+ def finish!
@type = nil
end
diff --git a/lib/backup/repositories.rb b/lib/backup/repositories.rb
index 0b5a62529b4..4c39e58c87d 100644
--- a/lib/backup/repositories.rb
+++ b/lib/backup/repositories.rb
@@ -40,7 +40,7 @@ module Backup
raise errors.pop unless errors.empty?
ensure
- strategy.wait
+ strategy.finish!
end
def restore
@@ -48,7 +48,7 @@ module Backup
enqueue_consecutive
ensure
- strategy.wait
+ strategy.finish!
cleanup_snippets_without_repositories
restore_object_pools
diff --git a/lib/gitlab/middleware/webhook_recursion_detection.rb b/lib/gitlab/middleware/webhook_recursion_detection.rb
new file mode 100644
index 00000000000..2677445852c
--- /dev/null
+++ b/lib/gitlab/middleware/webhook_recursion_detection.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Middleware
+ class WebhookRecursionDetection
+ def initialize(app)
+ @app = app
+ end
+
+ def call(env)
+ headers = ActionDispatch::Request.new(env).headers
+
+ ::Gitlab::WebHooks::RecursionDetection.set_from_headers(headers)
+
+ @app.call(env)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/web_hooks.rb b/lib/gitlab/web_hooks.rb
new file mode 100644
index 00000000000..349c7a020cc
--- /dev/null
+++ b/lib/gitlab/web_hooks.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module WebHooks
+ GITLAB_EVENT_HEADER = 'X-Gitlab-Event'
+ end
+end
diff --git a/lib/gitlab/web_hooks/recursion_detection.rb b/lib/gitlab/web_hooks/recursion_detection.rb
new file mode 100644
index 00000000000..34f70093dcd
--- /dev/null
+++ b/lib/gitlab/web_hooks/recursion_detection.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+# This module detects and blocks recursive webhook requests.
+#
+# Recursion can happen when a webhook has been configured to make a call
+# to its own GitLab instance (i.e., its API), and during the execution of
+# the call the webhook is triggered again to create an infinite loop of
+# being triggered.
+#
+# Additionally the module blocks a webhook once the number of requests to
+# the instance made by a series of webhooks triggering other webhooks reaches
+# a limit.
+#
+# Blocking recursive webhooks allows GitLab to continue to support workflows
+# that use webhooks to call the API non-recursively, or do not go on to
+# trigger an unreasonable number of other webhooks.
+module Gitlab
+ module WebHooks
+ module RecursionDetection
+ COUNT_LIMIT = 100
+ TOUCH_CACHE_TTL = 30.minutes
+
+ class << self
+ def set_from_headers(headers)
+ uuid = headers[UUID::HEADER]
+
+ return unless uuid
+
+ set_request_uuid(uuid)
+ end
+
+ def set_request_uuid(uuid)
+ UUID.instance.request_uuid = uuid
+ end
+
+ # Before a webhook is executed, `.register!` should be called.
+ # Adds the webhook ID to a cache (see `#cache_key_for_hook` for
+ # details of the cache).
+ def register!(hook)
+ return if disabled?(hook)
+
+ cache_key = cache_key_for_hook(hook)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ redis.multi do
+ redis.sadd(cache_key, hook.id)
+ redis.expire(cache_key, TOUCH_CACHE_TTL)
+ end
+ end
+ end
+
+ # Returns true if the webhook ID is present in the cache, or if the
+ # number of IDs in the cache exceeds the limit (see
+ # `#cache_key_for_hook` for details of the cache).
+ def block?(hook)
+ return false if disabled?(hook)
+
+ # If a request UUID has not been set then we know the request was not
+ # made by a webhook, and no recursion is possible.
+ return false unless UUID.instance.request_uuid
+
+ cache_key = cache_key_for_hook(hook)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ redis.sismember(cache_key, hook.id) ||
+ redis.scard(cache_key) >= COUNT_LIMIT
+ end
+ end
+
+ def header(hook)
+ UUID.instance.header(hook)
+ end
+
+ def to_log(hook)
+ {
+ uuid: UUID.instance.uuid_for_hook(hook),
+ ids: ::Gitlab::Redis::SharedState.with { |redis| redis.smembers(cache_key_for_hook(hook)).map(&:to_i) }
+ }
+ end
+
+ private
+
+ def disabled?(hook)
+ Feature.disabled?(:webhook_recursion_detection, hook.parent)
+ end
+
+ # Returns a cache key scoped to a UUID.
+ #
+ # The particular UUID will be either:
+ #
+ # - A UUID that was recycled from the request headers if the request was made by a webhook.
+ # - a new UUID initialized for the webhook.
+ #
+ # This means that cycles of webhooks that are triggered from other webhooks
+ # will share the same cache, and other webhooks will use a new cache.
+ def cache_key_for_hook(hook)
+ [:webhook_recursion_detection, UUID.instance.uuid_for_hook(hook)].join(':')
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/web_hooks/recursion_detection/uuid.rb b/lib/gitlab/web_hooks/recursion_detection/uuid.rb
new file mode 100644
index 00000000000..9c52399818d
--- /dev/null
+++ b/lib/gitlab/web_hooks/recursion_detection/uuid.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module WebHooks
+ module RecursionDetection
+ class UUID
+ HEADER = "#{::Gitlab::WebHooks::GITLAB_EVENT_HEADER}-UUID"
+
+ include Singleton
+
+ attr_accessor :request_uuid
+
+ def initialize
+ self.new_uuids_for_hooks = {}
+ end
+
+ class << self
+ # Back the Singleton with RequestStore so it is isolated to this request.
+ def instance
+ Gitlab::SafeRequestStore[:web_hook_recursion_detection_uuid] ||= new
+ end
+ end
+
+ # Returns a UUID, which will be either:
+ #
+ # - The UUID that was recycled from the request headers if the request was made by a webhook.
+ # - A new UUID initialized for the webhook.
+ def uuid_for_hook(hook)
+ request_uuid || new_uuid_for_hook(hook)
+ end
+
+ def header(hook)
+ { HEADER => uuid_for_hook(hook) }
+ end
+
+ private
+
+ attr_accessor :new_uuids_for_hooks
+
+ def new_uuid_for_hook(hook)
+ new_uuids_for_hooks[hook.id] ||= SecureRandom.uuid
+ end
+ end
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/backup.rake b/lib/tasks/gitlab/backup.rake
index 1e2462a0b93..cf922f55a7b 100644
--- a/lib/tasks/gitlab/backup.rake
+++ b/lib/tasks/gitlab/backup.rake
@@ -351,7 +351,7 @@ namespace :gitlab do
if Feature.enabled?(:gitaly_backup, default_enabled: :yaml)
max_concurrency = ENV['GITLAB_BACKUP_MAX_CONCURRENCY'].presence
max_storage_concurrency = ENV['GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY'].presence
- Backup::GitalyBackup.new(progress, parallel: max_concurrency, parallel_storage: max_storage_concurrency)
+ Backup::GitalyBackup.new(progress, max_parallelism: max_concurrency, storage_parallelism: max_storage_concurrency)
else
Backup::GitalyRpcBackup.new(progress)
end
diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake
index 18c68615637..b01a7902bf2 100644
--- a/lib/tasks/gitlab/gitaly.rake
+++ b/lib/tasks/gitlab/gitaly.rake
@@ -2,6 +2,41 @@
namespace :gitlab do
namespace :gitaly do
+ desc 'Installs gitaly for running tests within gitlab-development-kit'
+ task :test_install, [:dir, :storage_path, :repo] => :gitlab_environment do |t, args|
+ inside_gdk = Rails.env.test? && File.exist?(Rails.root.join('../GDK_ROOT'))
+
+ if ENV['FORCE_GITALY_INSTALL'] || !inside_gdk
+ Rake::Task["gitlab:gitaly:install"].invoke(*args)
+
+ next
+ end
+
+ gdk_gitaly_dir = ENV.fetch('GDK_GITALY', Rails.root.join('../gitaly'))
+
+ # Our test setup expects a git repo, so clone rather than copy
+ clone_repo(gdk_gitaly_dir, args.dir, clone_opts: %w[--depth 1]) unless Dir.exist?(args.dir)
+
+ # We assume the GDK gitaly already compiled binaries
+ build_dir = File.join(gdk_gitaly_dir, '_build')
+ FileUtils.cp_r(build_dir, args.dir)
+
+ # We assume the GDK gitaly already ran bundle install
+ bundle_dir = File.join(gdk_gitaly_dir, 'ruby', '.bundle')
+ FileUtils.cp_r(bundle_dir, File.join(args.dir, 'ruby'))
+
+ # For completeness we copy this for gitaly's make target
+ ruby_bundle_file = File.join(gdk_gitaly_dir, '.ruby-bundle')
+ FileUtils.cp_r(ruby_bundle_file, args.dir)
+
+ gitaly_binary = File.join(build_dir, 'bin', 'gitaly')
+ warn_gitaly_out_of_date!(gitaly_binary, Gitlab::GitalyClient.expected_server_version)
+ rescue Errno::ENOENT => e
+ puts "Could not copy files, did you run `gdk update`? Error: #{e.message}"
+
+ raise
+ end
+
desc 'GitLab | Gitaly | Clone and checkout gitaly'
task :clone, [:dir, :storage_path, :repo] => :gitlab_environment do |t, args|
warn_user_is_not_gitlab
@@ -25,6 +60,9 @@ Usage: rake "gitlab:gitaly:install[/installation/dir,/storage/path]")
storage_paths = { 'default' => args.storage_path }
Gitlab::SetupHelper::Gitaly.create_configuration(args.dir, storage_paths)
+ # In CI we run scripts/gitaly-test-build
+ next if ENV['CI'].present?
+
Dir.chdir(args.dir) do
Bundler.with_original_env do
env = { "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil }
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 98356de7376..fbcc5d5a82e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -42529,6 +42529,9 @@ msgstr ""
msgid "mrWidget|Merge blocked: this merge request must be approved."
msgstr ""
+msgid "mrWidget|Merge blocked: you can only merge once above items are resolved."
+msgstr ""
+
msgid "mrWidget|Merge failed."
msgstr ""
@@ -42637,9 +42640,6 @@ msgstr ""
msgid "mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure, or check the %{linkStart}troubleshooting documentation%{linkEnd} to see other possible actions."
msgstr ""
-msgid "mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure."
-msgstr ""
-
msgid "mrWidget|The source branch has been deleted"
msgstr ""
diff --git a/scripts/gitaly-test-build b/scripts/gitaly-test-build
index adc9b56ca4f..e6afadccc7e 100755
--- a/scripts/gitaly-test-build
+++ b/scripts/gitaly-test-build
@@ -13,6 +13,8 @@ class GitalyTestBuild
include GitalySetup
def run
+ set_bundler_config
+
# If we have the binaries from the cache, we can skip building them again
if File.exist?(tmp_tests_gitaly_bin_dir)
GitalySetup::LOGGER.debug "Gitaly binary already built. Skip building...\n"
diff --git a/scripts/gitaly-test-spawn b/scripts/gitaly-test-spawn
index 3bf2de76760..e7e25a217b2 100755
--- a/scripts/gitaly-test-spawn
+++ b/scripts/gitaly-test-spawn
@@ -9,8 +9,17 @@ class GitalyTestSpawn
include GitalySetup
def run
- install_gitaly_gems
- spawn_gitaly
+ set_bundler_config
+ install_gitaly_gems if ENV['CI']
+ check_gitaly_config!
+
+ # # Uncomment line below to see all gitaly logs merged into CI trace
+ # spawn('sleep 1; tail -f log/gitaly-test.log')
+
+ # In local development this pid file is used by rspec.
+ IO.write(File.expand_path('../tmp/tests/gitaly.pid', __dir__), start_gitaly)
+ IO.write(File.expand_path('../tmp/tests/gitaly2.pid', __dir__), start_gitaly2)
+ IO.write(File.expand_path('../tmp/tests/praefect.pid', __dir__), start_praefect)
end
end
diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js
index 7c5faeb8dc1..e9c48b60da4 100644
--- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js
+++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js
@@ -1,7 +1,7 @@
import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
-import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue';
+import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue';
describe('CE IssueCardTimeInfo component', () => {
useFakeDate(2020, 11, 11);
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js
index f24c090fa92..66428ee0492 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -5,8 +5,8 @@ import AxiosMockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
-import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
+import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
+import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
@@ -17,15 +17,15 @@ import {
filteredTokens,
locationSearch,
urlParams,
-} from 'jest/issues_list/mock_data';
+} from 'jest/issues/list/mock_data';
import createFlash, { FLASH_TYPES } from '~/flash';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
-import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
-import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue';
+import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
+import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue';
import {
CREATED_DESC,
DUE_DATE_OVERDUE,
@@ -41,9 +41,9 @@ import {
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
urlSortParams,
-} from '~/issues_list/constants';
-import eventHub from '~/issues_list/eventhub';
-import { getSortOptions } from '~/issues_list/utils';
+} from '~/issues/list/constants';
+import eventHub from '~/issues/list/eventhub';
+import { getSortOptions } from '~/issues/list/utils';
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { joinPaths } from '~/lib/utils/url_utility';
diff --git a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js
index 633799816d8..d6d6bb14e9d 100644
--- a/spec/frontend/issues_list/components/jira_issues_import_status_app_spec.js
+++ b/spec/frontend/issues/list/components/jira_issues_import_status_app_spec.js
@@ -1,7 +1,7 @@
import { GlAlert, GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
-import JiraIssuesImportStatus from '~/issues_list/components/jira_issues_import_status_app.vue';
+import JiraIssuesImportStatus from '~/issues/list/components/jira_issues_import_status_app.vue';
describe('JiraIssuesImportStatus', () => {
const issuesPath = 'gitlab-org/gitlab-test/-/issues';
diff --git a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js
index 1c9a87e8af2..0c52e66ff14 100644
--- a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js
+++ b/spec/frontend/issues/list/components/new_issue_dropdown_spec.js
@@ -2,8 +2,8 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue';
-import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
+import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue';
+import searchProjectsQuery from '~/issues/list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import {
emptySearchProjectsQueryResponse,
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index 948699876ce..948699876ce 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js
index 8e1d70db92d..0e4979fd7b4 100644
--- a/spec/frontend/issues_list/utils_spec.js
+++ b/spec/frontend/issues/list/utils_spec.js
@@ -7,14 +7,14 @@ import {
locationSearchWithSpecialValues,
urlParams,
urlParamsWithSpecialValues,
-} from 'jest/issues_list/mock_data';
+} from 'jest/issues/list/mock_data';
import {
defaultPageSizeParams,
DUE_DATE_VALUES,
largePageSizeParams,
RELATIVE_POSITION_ASC,
urlSortParams,
-} from '~/issues_list/constants';
+} from '~/issues/list/constants';
import {
convertToApiParams,
convertToSearchQuery,
@@ -24,7 +24,7 @@ import {
getInitialPageParams,
getSortKey,
getSortOptions,
-} from '~/issues_list/utils';
+} from '~/issues/list/utils';
describe('getInitialPageParams', () => {
it.each(Object.keys(urlSortParams))(
diff --git a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js
index bdad0bada5f..1900b53ac11 100644
--- a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js
@@ -15,35 +15,12 @@ describe('Merge request widget merge checks failed state component', () => {
});
it.each`
- mrState | displayText
- ${{ isPipelineFailed: true }} | ${'pipelineFailed'}
- ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'}
- ${{ hasMergeableDiscussionsState: true }} | ${'unresolvedDiscussions'}
+ mrState | displayText
+ ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'}
+ ${{ blockingMergeRequests: { total_count: 1 } }} | ${'blockingMergeRequests'}
`('display $displayText text for $mrState', ({ mrState, displayText }) => {
factory({ mr: mrState });
expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]);
});
-
- describe('unresolved discussions', () => {
- it('renders jump to button', () => {
- factory({ mr: { hasMergeableDiscussionsState: true } });
-
- expect(wrapper.find('[data-testid="jumpToUnresolved"]').exists()).toBe(true);
- });
-
- it('renders resolve thread button', () => {
- factory({
- mr: {
- hasMergeableDiscussionsState: true,
- createIssueToResolveDiscussionsPath: 'https://gitlab.com',
- },
- });
-
- expect(wrapper.find('[data-testid="resolveIssue"]').exists()).toBe(true);
- expect(wrapper.find('[data-testid="resolveIssue"]').attributes('href')).toBe(
- 'https://gitlab.com',
- );
- });
- });
});
diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb
index 2ccde517533..cd0d984fbdb 100644
--- a/spec/lib/backup/gitaly_backup_spec.rb
+++ b/spec/lib/backup/gitaly_backup_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe Backup::GitalyBackup do
- let(:parallel) { nil }
- let(:parallel_storage) { nil }
+ let(:max_parallelism) { nil }
+ let(:storage_parallelism) { nil }
let(:progress) do
Tempfile.new('progress').tap do |progress|
@@ -23,7 +23,7 @@ RSpec.describe Backup::GitalyBackup do
progress.close
end
- subject { described_class.new(progress, parallel: parallel, parallel_storage: parallel_storage) }
+ subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism) }
context 'unknown' do
it 'fails to start unknown' do
@@ -48,7 +48,7 @@ RSpec.describe Backup::GitalyBackup do
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.wait
+ subject.finish!
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle'))
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki.bundle'))
@@ -58,24 +58,24 @@ RSpec.describe Backup::GitalyBackup do
end
context 'parallel option set' do
- let(:parallel) { 3 }
+ let(:max_parallelism) { 3 }
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel', '3').and_call_original
subject.start(:create)
- subject.wait
+ subject.finish!
end
end
context 'parallel_storage option set' do
- let(:parallel_storage) { 3 }
+ let(:storage_parallelism) { 3 }
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel-storage', '3').and_call_original
subject.start(:create)
- subject.wait
+ subject.finish!
end
end
@@ -83,7 +83,7 @@ RSpec.describe Backup::GitalyBackup do
expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false'))
subject.start(:create)
- expect { subject.wait }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1')
+ expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1')
end
end
@@ -115,7 +115,7 @@ RSpec.describe Backup::GitalyBackup do
expect(Open3).to receive(:popen2).with(ssl_env, anything, 'create', '-path', anything).and_call_original
subject.start(:create)
- subject.wait
+ subject.finish!
end
end
end
@@ -145,7 +145,7 @@ RSpec.describe Backup::GitalyBackup do
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.wait
+ subject.finish!
collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) }
@@ -157,24 +157,24 @@ RSpec.describe Backup::GitalyBackup do
end
context 'parallel option set' do
- let(:parallel) { 3 }
+ let(:max_parallelism) { 3 }
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel', '3').and_call_original
subject.start(:restore)
- subject.wait
+ subject.finish!
end
end
context 'parallel_storage option set' do
- let(:parallel_storage) { 3 }
+ let(:storage_parallelism) { 3 }
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel-storage', '3').and_call_original
subject.start(:restore)
- subject.wait
+ subject.finish!
end
end
@@ -182,7 +182,7 @@ RSpec.describe Backup::GitalyBackup do
expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false'))
subject.start(:restore)
- expect { subject.wait }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1')
+ expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1')
end
end
end
diff --git a/spec/lib/backup/gitaly_rpc_backup_spec.rb b/spec/lib/backup/gitaly_rpc_backup_spec.rb
index fb442f4a86f..14f9d27ca6e 100644
--- a/spec/lib/backup/gitaly_rpc_backup_spec.rb
+++ b/spec/lib/backup/gitaly_rpc_backup_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe Backup::GitalyRpcBackup do
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.wait
+ subject.finish!
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle'))
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki.bundle'))
@@ -52,7 +52,7 @@ RSpec.describe Backup::GitalyRpcBackup do
it 'logs an appropriate message', :aggregate_failures do
subject.start(:create)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
- subject.wait
+ subject.finish!
expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})")
expect(progress).to have_received(:puts).with("Error Fail in tests")
@@ -96,7 +96,7 @@ RSpec.describe Backup::GitalyRpcBackup do
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.wait
+ subject.finish!
collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) }
@@ -129,7 +129,7 @@ RSpec.describe Backup::GitalyRpcBackup do
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.wait
+ subject.finish!
end
context 'failure' do
@@ -143,7 +143,7 @@ RSpec.describe Backup::GitalyRpcBackup do
it 'logs an appropriate message', :aggregate_failures do
subject.start(:restore)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
- subject.wait
+ subject.finish!
expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})")
expect(progress).to have_received(:puts).with("Error Fail in tests")
diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb
index 85818038c9d..f3830da344b 100644
--- a/spec/lib/backup/repositories_spec.rb
+++ b/spec/lib/backup/repositories_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe Backup::Repositories do
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN)
expect(strategy).to have_received(:enqueue).with(project_snippet, Gitlab::GlRepository::SNIPPET)
expect(strategy).to have_received(:enqueue).with(personal_snippet, Gitlab::GlRepository::SNIPPET)
- expect(strategy).to have_received(:wait)
+ expect(strategy).to have_received(:finish!)
end
end
@@ -49,7 +49,7 @@ RSpec.describe Backup::Repositories do
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
- expect(strategy).to receive(:wait)
+ expect(strategy).to receive(:finish!)
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
end
@@ -91,7 +91,7 @@ RSpec.describe Backup::Repositories do
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
- expect(strategy).to receive(:wait)
+ expect(strategy).to receive(:finish!)
subject.dump(max_concurrency: 2, max_storage_concurrency: 2)
end
@@ -114,7 +114,7 @@ RSpec.describe Backup::Repositories do
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
- expect(strategy).to receive(:wait)
+ expect(strategy).to receive(:finish!)
subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
end
@@ -128,7 +128,7 @@ RSpec.describe Backup::Repositories do
projects.each do |project|
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
end
- expect(strategy).to receive(:wait)
+ expect(strategy).to receive(:finish!)
subject.dump(max_concurrency: 3, max_storage_concurrency: max_storage_concurrency)
end
@@ -184,7 +184,7 @@ RSpec.describe Backup::Repositories do
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN)
expect(strategy).to have_received(:enqueue).with(project_snippet, Gitlab::GlRepository::SNIPPET)
expect(strategy).to have_received(:enqueue).with(personal_snippet, Gitlab::GlRepository::SNIPPET)
- expect(strategy).to have_received(:wait)
+ expect(strategy).to have_received(:finish!)
end
context 'restoring object pools' do
diff --git a/spec/lib/gitlab/middleware/webhook_recursion_detection_spec.rb b/spec/lib/gitlab/middleware/webhook_recursion_detection_spec.rb
new file mode 100644
index 00000000000..c8dbc990f8c
--- /dev/null
+++ b/spec/lib/gitlab/middleware/webhook_recursion_detection_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'action_dispatch'
+require 'rack'
+require 'request_store'
+
+RSpec.describe Gitlab::Middleware::WebhookRecursionDetection do
+ let(:app) { double(:app) }
+ let(:middleware) { described_class.new(app) }
+ let(:env) { Rack::MockRequest.env_for("/").merge(headers) }
+
+ around do |example|
+ Gitlab::WithRequestStore.with_request_store { example.run }
+ end
+
+ describe '#call' do
+ subject(:call) { described_class.new(app).call(env) }
+
+ context 'when the recursion detection header is present' do
+ let(:new_uuid) { SecureRandom.uuid }
+ let(:headers) { { 'HTTP_X_GITLAB_EVENT_UUID' => new_uuid } }
+
+ it 'sets the request UUID from the header' do
+ expect(app).to receive(:call)
+ expect { call }.to change { Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid }.to(new_uuid)
+ end
+ end
+
+ context 'when recursion headers are not present' do
+ let(:headers) { {} }
+
+ it 'works without errors' do
+ expect(app).to receive(:call)
+
+ call
+
+ expect(Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/web_hooks/recursion_detection_spec.rb b/spec/lib/gitlab/web_hooks/recursion_detection_spec.rb
new file mode 100644
index 00000000000..4f92aa8b3d1
--- /dev/null
+++ b/spec/lib/gitlab/web_hooks/recursion_detection_spec.rb
@@ -0,0 +1,243 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::WebHooks::RecursionDetection, :clean_gitlab_redis_shared_state, :request_store do
+ let_it_be(:web_hook) { create(:project_hook) }
+
+ let!(:uuid_class) { described_class::UUID }
+
+ describe '.set_from_headers' do
+ let(:old_uuid) { SecureRandom.uuid }
+ let(:rack_headers) { Rack::MockRequest.env_for("/").merge(headers) }
+
+ subject(:set_from_headers) { described_class.set_from_headers(rack_headers) }
+
+ # Note, having a previous `request_uuid` value set before `.set_from_headers` is
+ # called is contrived and should not normally happen. However, testing with this scenario
+ # allows us to assert the ideal outcome if it ever were to happen.
+ before do
+ uuid_class.instance.request_uuid = old_uuid
+ end
+
+ context 'when the detection header is present' do
+ let(:new_uuid) { SecureRandom.uuid }
+
+ let(:headers) do
+ { uuid_class::HEADER => new_uuid }
+ end
+
+ it 'sets the request UUID value from the headers' do
+ set_from_headers
+
+ expect(uuid_class.instance.request_uuid).to eq(new_uuid)
+ end
+ end
+
+ context 'when detection header is not present' do
+ let(:headers) { {} }
+
+ it 'does not set the request UUID' do
+ set_from_headers
+
+ expect(uuid_class.instance.request_uuid).to eq(old_uuid)
+ end
+ end
+ end
+
+ describe '.set_request_uuid' do
+ it 'sets the request UUID value' do
+ new_uuid = SecureRandom.uuid
+
+ described_class.set_request_uuid(new_uuid)
+
+ expect(uuid_class.instance.request_uuid).to eq(new_uuid)
+ end
+ end
+
+ describe '.register!' do
+ let_it_be(:second_web_hook) { create(:project_hook) }
+ let_it_be(:third_web_hook) { create(:project_hook) }
+
+ def cache_key(hook)
+ described_class.send(:cache_key_for_hook, hook)
+ end
+
+ it 'stores IDs in the same cache when a request UUID is set, until the request UUID changes', :aggregate_failures do
+ # Register web_hook and second_web_hook against the same request UUID.
+ uuid_class.instance.request_uuid = SecureRandom.uuid
+ described_class.register!(web_hook)
+ described_class.register!(second_web_hook)
+ first_cache_key = cache_key(web_hook)
+ second_cache_key = cache_key(second_web_hook)
+
+ # Register third_web_hook against a new request UUID.
+ uuid_class.instance.request_uuid = SecureRandom.uuid
+ described_class.register!(third_web_hook)
+ third_cache_key = cache_key(third_web_hook)
+
+ expect(first_cache_key).to eq(second_cache_key)
+ expect(second_cache_key).not_to eq(third_cache_key)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ members = redis.smembers(first_cache_key).map(&:to_i)
+ expect(members).to contain_exactly(web_hook.id, second_web_hook.id)
+
+ members = redis.smembers(third_cache_key).map(&:to_i)
+ expect(members).to contain_exactly(third_web_hook.id)
+ end
+ end
+
+ it 'stores IDs in unique caches when no request UUID is present', :aggregate_failures do
+ described_class.register!(web_hook)
+ described_class.register!(second_web_hook)
+ described_class.register!(third_web_hook)
+
+ first_cache_key = cache_key(web_hook)
+ second_cache_key = cache_key(second_web_hook)
+ third_cache_key = cache_key(third_web_hook)
+
+ expect([first_cache_key, second_cache_key, third_cache_key].compact.length).to eq(3)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ members = redis.smembers(first_cache_key).map(&:to_i)
+ expect(members).to contain_exactly(web_hook.id)
+
+ members = redis.smembers(second_cache_key).map(&:to_i)
+ expect(members).to contain_exactly(second_web_hook.id)
+
+ members = redis.smembers(third_cache_key).map(&:to_i)
+ expect(members).to contain_exactly(third_web_hook.id)
+ end
+ end
+
+ it 'touches the storage ttl each time it is called', :aggregate_failures do
+ freeze_time do
+ described_class.register!(web_hook)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(cache_key(web_hook))).to eq(described_class::TOUCH_CACHE_TTL.to_i)
+ end
+ end
+
+ travel_to(1.minute.from_now) do
+ described_class.register!(second_web_hook)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.ttl(cache_key(web_hook))).to eq(described_class::TOUCH_CACHE_TTL.to_i)
+ end
+ end
+ end
+
+ it 'does not store anything if the feature flag is disabled' do
+ stub_feature_flags(webhook_recursion_detection: false)
+
+ described_class.register!(web_hook)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.exists(cache_key(web_hook))).to eq(false)
+ end
+ end
+ end
+
+ describe 'block?' do
+ let_it_be(:registered_web_hooks) { create_list(:project_hook, 2) }
+
+ subject(:block?) { described_class.block?(web_hook) }
+
+ before do
+ # Register some previous webhooks.
+ uuid_class.instance.request_uuid = SecureRandom.uuid
+
+ registered_web_hooks.each do |web_hook|
+ described_class.register!(web_hook)
+ end
+ end
+
+ it 'returns false if webhook should not be blocked' do
+ is_expected.to eq(false)
+ end
+
+ context 'when the webhook has previously fired' do
+ before do
+ described_class.register!(web_hook)
+ end
+
+ it 'returns true' do
+ is_expected.to eq(true)
+ end
+
+ it 'returns false if the feature flag is disabled' do
+ stub_feature_flags(webhook_recursion_detection: false)
+
+ is_expected.to eq(false)
+ end
+
+ context 'when the request UUID changes again' do
+ before do
+ uuid_class.instance.request_uuid = SecureRandom.uuid
+ end
+
+ it 'returns false' do
+ is_expected.to eq(false)
+ end
+ end
+ end
+
+ context 'when the count limit has been reached' do
+ let_it_be(:registered_web_hooks) { create_list(:project_hook, 2) }
+
+ before do
+ registered_web_hooks.each do |web_hook|
+ described_class.register!(web_hook)
+ end
+
+ stub_const("#{described_class.name}::COUNT_LIMIT", registered_web_hooks.size)
+ end
+
+ it 'returns true' do
+ is_expected.to eq(true)
+ end
+
+ it 'returns false if the feature flag is disabled' do
+ stub_feature_flags(webhook_recursion_detection: false)
+
+ is_expected.to eq(false)
+ end
+
+ context 'when the request UUID changes again' do
+ before do
+ uuid_class.instance.request_uuid = SecureRandom.uuid
+ end
+
+ it 'returns false' do
+ is_expected.to eq(false)
+ end
+ end
+ end
+ end
+
+ describe '.header' do
+ subject(:header) { described_class.header(web_hook) }
+
+ it 'returns a header with the UUID value' do
+ uuid = SecureRandom.uuid
+ allow(uuid_class.instance).to receive(:uuid_for_hook).and_return(uuid)
+
+ is_expected.to eq({ uuid_class::HEADER => uuid })
+ end
+ end
+
+ describe '.to_log' do
+ subject(:to_log) { described_class.to_log(web_hook) }
+
+ it 'returns the UUID value and all registered webhook IDs in a Hash' do
+ uuid = SecureRandom.uuid
+ allow(uuid_class.instance).to receive(:uuid_for_hook).and_return(uuid)
+ registered_web_hooks = create_list(:project_hook, 2)
+ registered_web_hooks.each { described_class.register!(_1) }
+
+ is_expected.to eq({ uuid: uuid, ids: registered_web_hooks.map(&:id) })
+ end
+ end
+end
diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb
index f0ee9a613d8..ec2eca96755 100644
--- a/spec/models/hooks/project_hook_spec.rb
+++ b/spec/models/hooks/project_hook_spec.rb
@@ -40,6 +40,15 @@ RSpec.describe ProjectHook do
end
end
+ describe '#parent' do
+ it 'returns the associated project' do
+ project = build(:project)
+ hook = build(:project_hook, project: project)
+
+ expect(hook.parent).to eq(project)
+ end
+ end
+
describe '#application_context' do
let_it_be(:hook) { build(:project_hook) }
diff --git a/spec/models/hooks/service_hook_spec.rb b/spec/models/hooks/service_hook_spec.rb
index 4ce2e729d89..85f433f5f81 100644
--- a/spec/models/hooks/service_hook_spec.rb
+++ b/spec/models/hooks/service_hook_spec.rb
@@ -31,6 +31,36 @@ RSpec.describe ServiceHook do
end
end
+ describe '#parent' do
+ let(:hook) { build(:service_hook, integration: integration) }
+
+ context 'with a project-level integration' do
+ let(:project) { build(:project) }
+ let(:integration) { build(:integration, project: project) }
+
+ it 'returns the associated project' do
+ expect(hook.parent).to eq(project)
+ end
+ end
+
+ context 'with a group-level integration' do
+ let(:group) { build(:group) }
+ let(:integration) { build(:integration, :group, group: group) }
+
+ it 'returns the associated group' do
+ expect(hook.parent).to eq(group)
+ end
+ end
+
+ context 'with an instance-level integration' do
+ let(:integration) { build(:integration, :instance) }
+
+ it 'returns nil' do
+ expect(hook.parent).to be_nil
+ end
+ end
+ end
+
describe '#application_context' do
let(:hook) { build(:service_hook) }
diff --git a/spec/models/integrations/datadog_spec.rb b/spec/models/integrations/datadog_spec.rb
index 9c3ff7aa35b..0607505e7d5 100644
--- a/spec/models/integrations/datadog_spec.rb
+++ b/spec/models/integrations/datadog_spec.rb
@@ -167,7 +167,7 @@ RSpec.describe Integrations::Datadog do
context 'with pipeline data' do
let(:data) { pipeline_data }
- let(:expected_headers) { { WebHookService::GITLAB_EVENT_HEADER => 'Pipeline Hook' } }
+ let(:expected_headers) { { ::Gitlab::WebHooks::GITLAB_EVENT_HEADER => 'Pipeline Hook' } }
let(:expected_body) { data.with_retried_builds.to_json }
it { expect(a_request(:post, expected_hook_url).with(headers: expected_headers, body: expected_body)).to have_been_made }
@@ -175,7 +175,7 @@ RSpec.describe Integrations::Datadog do
context 'with job data' do
let(:data) { build_data }
- let(:expected_headers) { { WebHookService::GITLAB_EVENT_HEADER => 'Job Hook' } }
+ let(:expected_headers) { { ::Gitlab::WebHooks::GITLAB_EVENT_HEADER => 'Job Hook' } }
let(:expected_body) { data.to_json }
it { expect(a_request(:post, expected_hook_url).with(headers: expected_headers, body: expected_body)).to have_been_made }
diff --git a/spec/requests/api/ci/triggers_spec.rb b/spec/requests/api/ci/triggers_spec.rb
index d270a16d28d..a036a55f5f3 100644
--- a/spec/requests/api/ci/triggers_spec.rb
+++ b/spec/requests/api/ci/triggers_spec.rb
@@ -162,7 +162,7 @@ RSpec.describe API::Ci::Triggers do
expect do
post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{trigger_token}"),
params: { ref: 'refs/heads/other-branch' },
- headers: { WebHookService::GITLAB_EVENT_HEADER => 'Pipeline Hook' }
+ headers: { ::Gitlab::WebHooks::GITLAB_EVENT_HEADER => 'Pipeline Hook' }
end.not_to change(Ci::Pipeline, :count)
expect(response).to have_gitlab_http_status(:forbidden)
diff --git a/spec/requests/recursive_webhook_detection_spec.rb b/spec/requests/recursive_webhook_detection_spec.rb
new file mode 100644
index 00000000000..09a352da84b
--- /dev/null
+++ b/spec/requests/recursive_webhook_detection_spec.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Recursive webhook detection', :sidekiq_inline, :clean_gitlab_redis_shared_state, :request_store do
+ include StubRequests
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository, namespace: user.namespace, creator: user) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:project_hook) { create(:project_hook, project: project, merge_requests_events: true) }
+ let_it_be(:system_hook) { create(:system_hook, merge_requests_events: true) }
+
+ # Trigger a change to the merge request to fire the webhooks.
+ def trigger_web_hooks
+ params = { merge_request: { description: FFaker::Lorem.sentence } }
+ put project_merge_request_path(project, merge_request), params: params, headers: headers
+ end
+
+ def stub_requests
+ stub_full_request(project_hook.url, method: :post, ip_address: '8.8.8.8')
+ stub_full_request(system_hook.url, method: :post, ip_address: '8.8.8.9')
+ end
+
+ before do
+ login_as(user)
+ end
+
+ context 'when the request headers include the recursive webhook detection header' do
+ let(:uuid) { SecureRandom.uuid }
+ let(:headers) { { Gitlab::WebHooks::RecursionDetection::UUID::HEADER => uuid } }
+
+ it 'executes all webhooks, logs no errors, and the webhook requests contain the same UUID header', :aggregate_failures do
+ stub_requests
+
+ expect(Gitlab::AuthLogger).not_to receive(:error)
+
+ trigger_web_hooks
+
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url))
+ .with { |req| req.headers['X-Gitlab-Event-Uuid'] == uuid }
+ .once
+ expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url))
+ .with { |req| req.headers['X-Gitlab-Event-Uuid'] == uuid }
+ .once
+ end
+
+ shared_examples 'when the feature flag is disabled' do
+ it 'executes and logs no errors' do
+ stub_feature_flags(webhook_recursion_detection: false)
+ stub_requests
+
+ expect(Gitlab::AuthLogger).not_to receive(:error)
+
+ trigger_web_hooks
+
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)).once
+ expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)).once
+ end
+ end
+
+ context 'when one of the webhooks is recursive' do
+ before do
+ # Recreate the necessary state for the previous request to be
+ # considered made from the webhook.
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(uuid)
+ Gitlab::WebHooks::RecursionDetection.register!(project_hook)
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(nil)
+ end
+
+ it 'executes all webhooks and logs an error for the recursive hook', :aggregate_failures do
+ stub_requests
+
+ expect(Gitlab::AuthLogger).to receive(:error).with(
+ include(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: project_hook.id,
+ recursion_detection: {
+ uuid: uuid,
+ ids: [project_hook.id]
+ }
+ )
+ ).twice # Twice: once in `#async_execute`, and again in `#execute`.
+
+ trigger_web_hooks
+
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)).once
+ expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)).once
+ end
+
+ include_examples 'when the feature flag is disabled'
+ end
+
+ context 'when the count limit has been reached' do
+ let_it_be(:previous_hooks) { create_list(:project_hook, 3) }
+
+ before do
+ stub_const('Gitlab::WebHooks::RecursionDetection::COUNT_LIMIT', 2)
+ # Recreate the necessary state for a number of previous webhooks to
+ # have been triggered previously.
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(uuid)
+ previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) }
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(nil)
+ end
+
+ it 'executes and logs errors for all hooks', :aggregate_failures do
+ stub_requests
+ previous_hook_ids = previous_hooks.map(&:id)
+
+ expect(Gitlab::AuthLogger).to receive(:error).with(
+ include(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: project_hook.id,
+ recursion_detection: {
+ uuid: uuid,
+ ids: include(*previous_hook_ids)
+ }
+ )
+ ).twice
+ expect(Gitlab::AuthLogger).to receive(:error).with(
+ include(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: system_hook.id,
+ recursion_detection: {
+ uuid: uuid,
+ ids: include(*previous_hook_ids)
+ }
+ )
+ ).twice
+
+ trigger_web_hooks
+
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)).once
+ expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)).once
+ end
+ end
+
+ include_examples 'when the feature flag is disabled'
+ end
+
+ context 'when the recursive webhook detection header is absent' do
+ let(:headers) { {} }
+
+ let(:uuid_header_spy) do
+ Class.new do
+ attr_reader :values
+
+ def initialize
+ @values = []
+ end
+
+ def to_proc
+ proc do |method, *args|
+ method.call(*args).tap do |headers|
+ @values << headers[Gitlab::WebHooks::RecursionDetection::UUID::HEADER]
+ end
+ end
+ end
+ end.new
+ end
+
+ before do
+ allow(Gitlab::WebHooks::RecursionDetection).to receive(:header).at_least(:once).and_wrap_original(&uuid_header_spy)
+ end
+
+ it 'executes all webhooks, logs no errors, and the webhook requests contain different UUID headers', :aggregate_failures do
+ stub_requests
+
+ expect(Gitlab::AuthLogger).not_to receive(:error)
+
+ trigger_web_hooks
+
+ uuid_headers = uuid_header_spy.values
+
+ expect(uuid_headers).to all(be_present)
+ expect(uuid_headers.uniq.length).to eq(2)
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url))
+ .with { |req| uuid_headers.include?(req.headers['X-Gitlab-Event-Uuid']) }
+ .once
+ expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url))
+ .with { |req| uuid_headers.include?(req.headers['X-Gitlab-Event-Uuid']) }
+ .once
+ end
+
+ it 'uses new UUID values between requests' do
+ stub_requests
+
+ trigger_web_hooks
+ trigger_web_hooks
+
+ uuid_headers = uuid_header_spy.values
+
+ expect(uuid_headers).to all(be_present)
+ expect(uuid_headers.length).to eq(4)
+ expect(uuid_headers.uniq.length).to eq(4)
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url)).twice
+ expect(WebMock).to have_requested(:post, stubbed_hostname(system_hook.url)).twice
+ end
+ end
+end
diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb
index 2aebd2adab9..7d933ea9c5c 100644
--- a/spec/services/web_hook_service_spec.rb
+++ b/spec/services/web_hook_service_spec.rb
@@ -2,20 +2,12 @@
require 'spec_helper'
-RSpec.describe WebHookService do
+RSpec.describe WebHookService, :request_store, :clean_gitlab_redis_shared_state do
include StubRequests
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:project_hook) { create(:project_hook, project: project) }
- let(:headers) do
- {
- 'Content-Type' => 'application/json',
- 'User-Agent' => "GitLab/#{Gitlab::VERSION}",
- 'X-Gitlab-Event' => 'Push Hook'
- }
- end
-
let(:data) do
{ before: 'oldrev', after: 'newrev', ref: 'ref' }
end
@@ -61,6 +53,21 @@ RSpec.describe WebHookService do
end
describe '#execute' do
+ let!(:uuid) { SecureRandom.uuid }
+ let(:headers) do
+ {
+ 'Content-Type' => 'application/json',
+ 'User-Agent' => "GitLab/#{Gitlab::VERSION}",
+ 'X-Gitlab-Event' => 'Push Hook',
+ 'X-Gitlab-Event-UUID' => uuid
+ }
+ end
+
+ before do
+ # Set a stable value for the `X-Gitlab-Event-UUID` header.
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(uuid)
+ end
+
context 'when token is defined' do
let_it_be(:project_hook) { create(:project_hook, :token) }
@@ -127,11 +134,74 @@ RSpec.describe WebHookService do
expect(service_instance.execute).to eq({ status: :error, message: 'Hook disabled' })
end
+ it 'executes and registers the hook with the recursion detection', :aggregate_failures do
+ stub_full_request(project_hook.url, method: :post)
+ cache_key = Gitlab::WebHooks::RecursionDetection.send(:cache_key_for_hook, project_hook)
+
+ ::Gitlab::Redis::SharedState.with do |redis|
+ expect { service_instance.execute }.to change {
+ redis.sismember(cache_key, project_hook.id)
+ }.to(true)
+ end
+
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url))
+ .with(headers: headers)
+ .once
+ end
+
+ it 'executes and logs if a recursive web hook is detected', :aggregate_failures do
+ stub_full_request(project_hook.url, method: :post)
+ Gitlab::WebHooks::RecursionDetection.register!(project_hook)
+
+ expect(Gitlab::AuthLogger).to receive(:error).with(
+ include(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: project_hook.id,
+ hook_type: 'ProjectHook',
+ hook_name: 'push_hooks',
+ recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook),
+ 'correlation_id' => kind_of(String)
+ )
+ )
+
+ service_instance.execute
+
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url))
+ .with(headers: headers)
+ .once
+ end
+
+ it 'executes and logs if the recursion count limit would be exceeded', :aggregate_failures do
+ stub_full_request(project_hook.url, method: :post)
+ stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3)
+ previous_hooks = create_list(:project_hook, 3)
+ previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) }
+
+ expect(Gitlab::AuthLogger).to receive(:error).with(
+ include(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: project_hook.id,
+ hook_type: 'ProjectHook',
+ hook_name: 'push_hooks',
+ recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook),
+ 'correlation_id' => kind_of(String)
+ )
+ )
+
+ service_instance.execute
+
+ expect(WebMock).to have_requested(:post, stubbed_hostname(project_hook.url))
+ .with(headers: headers)
+ .once
+ end
+
it 'handles exceptions' do
exceptions = Gitlab::HTTP::HTTP_ERRORS + [
Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError
]
+ allow(Gitlab::WebHooks::RecursionDetection).to receive(:block?).and_return(false)
+
exceptions.each do |exception_class|
exception = exception_class.new('Exception message')
project_hook.enable!
@@ -420,6 +490,57 @@ RSpec.describe WebHookService do
end
end
+ context 'recursion detection' do
+ before do
+ # Set a request UUID so `RecursionDetection.block?` will query redis.
+ Gitlab::WebHooks::RecursionDetection.set_request_uuid(SecureRandom.uuid)
+ end
+
+ it 'queues a worker and logs an error if the call chain limit would be exceeded' do
+ stub_const("#{Gitlab::WebHooks::RecursionDetection.name}::COUNT_LIMIT", 3)
+ previous_hooks = create_list(:project_hook, 3)
+ previous_hooks.each { Gitlab::WebHooks::RecursionDetection.register!(_1) }
+
+ expect(WebHookWorker).to receive(:perform_async)
+ expect(Gitlab::AuthLogger).to receive(:error).with(
+ include(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: project_hook.id,
+ hook_type: 'ProjectHook',
+ hook_name: 'push_hooks',
+ recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook),
+ 'correlation_id' => kind_of(String),
+ 'meta.project' => project.full_path,
+ 'meta.related_class' => 'ProjectHook',
+ 'meta.root_namespace' => project.root_namespace.full_path
+ )
+ )
+
+ service_instance.async_execute
+ end
+
+ it 'queues a worker and logs an error if a recursive call chain is detected' do
+ Gitlab::WebHooks::RecursionDetection.register!(project_hook)
+
+ expect(WebHookWorker).to receive(:perform_async)
+ expect(Gitlab::AuthLogger).to receive(:error).with(
+ include(
+ message: 'Webhook recursion detected and will be blocked in future',
+ hook_id: project_hook.id,
+ hook_type: 'ProjectHook',
+ hook_name: 'push_hooks',
+ recursion_detection: Gitlab::WebHooks::RecursionDetection.to_log(project_hook),
+ 'correlation_id' => kind_of(String),
+ 'meta.project' => project.full_path,
+ 'meta.related_class' => 'ProjectHook',
+ 'meta.root_namespace' => project.root_namespace.full_path
+ )
+ )
+
+ service_instance.async_execute
+ end
+ end
+
context 'when hook has custom context attributes' do
it 'includes the attributes in the worker context' do
expect(WebHookWorker).to receive(:perform_async) do
diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb
index cc0ddfdedac..923051a2e04 100644
--- a/spec/support/helpers/gitaly_setup.rb
+++ b/spec/support/helpers/gitaly_setup.rb
@@ -9,13 +9,8 @@
require 'securerandom'
require 'socket'
require 'logger'
-require 'bundler'
module GitalySetup
- extend self
-
- REPOS_STORAGE = 'default'
-
LOGGER = begin
default_name = ENV['CI'] ? 'DEBUG' : 'WARN'
level_name = ENV['GITLAB_TESTING_LOG_LEVEL']&.upcase
@@ -59,12 +54,9 @@ module GitalySetup
{
'HOME' => expand_path('tmp/tests'),
'GEM_PATH' => Gem.path.join(':'),
+ 'BUNDLE_APP_CONFIG' => File.join(gemfile_dir, '.bundle'),
'BUNDLE_INSTALL_FLAGS' => nil,
- 'BUNDLE_IGNORE_CONFIG' => '1',
- 'BUNDLE_PATH' => bundle_path,
'BUNDLE_GEMFILE' => gemfile,
- 'BUNDLE_JOBS' => '4',
- 'BUNDLE_RETRY' => '3',
'RUBYOPT' => nil,
# Git hooks can't run during tests as the internal API is not running.
@@ -73,20 +65,17 @@ module GitalySetup
}
end
- def bundle_path
- # Allow the user to override BUNDLE_PATH if they need to
- return ENV['GITALY_TEST_BUNDLE_PATH'] if ENV['GITALY_TEST_BUNDLE_PATH']
+ # rubocop:disable GitlabSecurity/SystemCommandInjection
+ def set_bundler_config
+ system('bundle config set --local jobs 4', chdir: gemfile_dir)
+ system('bundle config set --local retry 3', chdir: gemfile_dir)
if ENV['CI']
- expand_path('vendor/gitaly-ruby')
- else
- explicit_path = Bundler.configured_bundle_path.explicit_path
-
- return unless explicit_path
-
- expand_path(explicit_path)
+ bundle_path = expand_path('vendor/gitaly-ruby')
+ system('bundle', 'config', 'set', '--local', 'path', bundle_path, chdir: gemfile_dir)
end
end
+ # rubocop:enable GitlabSecurity/SystemCommandInjection
def config_path(service)
case service
@@ -99,10 +88,6 @@ module GitalySetup
end
end
- def repos_path(storage = REPOS_STORAGE)
- Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path
- end
-
def service_binary(service)
case service
when :gitaly, :gitaly2
@@ -211,104 +196,4 @@ module GitalySetup
raise "could not connect to #{socket}"
end
-
- def gitaly_socket_path
- Gitlab::GitalyClient.address(REPOS_STORAGE).delete_prefix('unix:')
- end
-
- def gitaly_dir
- socket_path = gitaly_socket_path
- socket_path = File.expand_path(gitaly_socket_path) if expand_path_for_socket?
-
- File.dirname(socket_path)
- end
-
- # Linux fails with "bind: invalid argument" if a UNIX socket path exceeds 108 characters:
- # https://github.com/golang/go/issues/6895. We use absolute paths in CI to ensure
- # that changes in the current working directory don't affect GRPC reconnections.
- def expand_path_for_socket?
- !!ENV['CI']
- end
-
- def setup_gitaly
- unless ENV['CI']
- # In CI Gitaly is built in the setup-test-env job and saved in the
- # artifacts. So when tests are started, there's no need to build Gitaly.
- build_gitaly
- end
-
- Gitlab::SetupHelper::Gitaly.create_configuration(
- gitaly_dir,
- { 'default' => repos_path },
- force: true,
- options: {
- prometheus_listen_addr: 'localhost:9236'
- }
- )
- Gitlab::SetupHelper::Gitaly.create_configuration(
- gitaly_dir,
- { 'default' => repos_path },
- force: true,
- options: {
- internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"),
- gitaly_socket: "gitaly2.socket",
- config_filename: "gitaly2.config.toml"
- }
- )
- Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true)
- end
-
- def socket_path(service)
- File.join(tmp_tests_gitaly_dir, "#{service}.socket")
- end
-
- def praefect_socket_path
- "unix:" + socket_path(:praefect)
- end
-
- def wait(service)
- sleep_time = 10
- sleep_interval = 0.1
- socket = socket_path(service)
-
- Integer(sleep_time / sleep_interval).times do
- Socket.unix(socket)
- return
- rescue StandardError
- sleep sleep_interval
- end
-
- raise "could not connect to #{service} at #{socket.inspect} after #{sleep_time} seconds"
- end
-
- def stop(pid)
- Process.kill('KILL', pid)
- rescue Errno::ESRCH
- # The process can already be gone if the test run was INTerrupted.
- end
-
- def spawn_gitaly
- check_gitaly_config!
-
- gitaly_pid = start_gitaly
- gitaly2_pid = start_gitaly2
- praefect_pid = start_praefect
-
- Kernel.at_exit do
- # In CI this function is called by scripts/gitaly-test-spawn, triggered a
- # before_script. Gitaly needs to remain running until the container is
- # stopped.
- next if ENV['CI']
-
- pids = [gitaly_pid, gitaly2_pid, praefect_pid]
- pids.each { |pid| stop(pid) }
- end
-
- wait('gitaly')
- wait('praefect')
- rescue StandardError
- message = 'gitaly spawn failed'
- message += " (try `rm -rf #{gitaly_dir}` ?)" unless ENV['CI']
- raise message
- end
end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index ecd2baa0ca6..294e5bdd085 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
require 'parallel'
-require_relative 'gitaly_setup'
module TestEnv
extend self
@@ -94,6 +93,7 @@ module TestEnv
}.freeze
TMP_TEST_PATH = Rails.root.join('tmp', 'tests').freeze
+ REPOS_STORAGE = 'default'
SECOND_STORAGE_PATH = Rails.root.join('tmp', 'tests', 'second_storage')
SETUP_METHODS = %i[setup_gitaly setup_gitlab_shell setup_workhorse setup_factory_repo setup_forked_repo].freeze
@@ -128,7 +128,7 @@ module TestEnv
# Can be overriden
def post_init
- start_gitaly
+ start_gitaly(gitaly_dir)
end
# Clean /tmp/tests
@@ -142,7 +142,7 @@ module TestEnv
end
FileUtils.mkdir_p(
- Gitlab::GitalyClient::StorageSettings.allow_disk_access { GitalySetup.repos_path }
+ Gitlab::GitalyClient::StorageSettings.allow_disk_access { TestEnv.repos_path }
)
FileUtils.mkdir_p(SECOND_STORAGE_PATH)
FileUtils.mkdir_p(backup_path)
@@ -158,28 +158,111 @@ module TestEnv
def setup_gitaly
component_timed_setup('Gitaly',
- install_dir: GitalySetup.gitaly_dir,
+ install_dir: gitaly_dir,
version: Gitlab::GitalyClient.expected_server_version,
- task: "gitlab:gitaly:clone",
- fresh_install: ENV.key?('FORCE_GITALY_INSTALL'),
- task_args: [GitalySetup.gitaly_dir, GitalySetup.repos_path, gitaly_url].compact) do
- GitalySetup.setup_gitaly
- end
+ task: "gitlab:gitaly:test_install",
+ task_args: [gitaly_dir, repos_path, gitaly_url].compact) do
+ Gitlab::SetupHelper::Gitaly.create_configuration(
+ gitaly_dir,
+ { 'default' => repos_path },
+ force: true,
+ options: {
+ prometheus_listen_addr: 'localhost:9236'
+ }
+ )
+ Gitlab::SetupHelper::Gitaly.create_configuration(
+ gitaly_dir,
+ { 'default' => repos_path },
+ force: true,
+ options: {
+ internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"),
+ gitaly_socket: "gitaly2.socket",
+ config_filename: "gitaly2.config.toml"
+ }
+ )
+ Gitlab::SetupHelper::Praefect.create_configuration(gitaly_dir, { 'praefect' => repos_path }, force: true)
+ end
+ end
+
+ def gitaly_socket_path
+ Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '')
+ end
+
+ def gitaly_dir
+ socket_path = gitaly_socket_path
+ socket_path = File.expand_path(gitaly_socket_path) if expand_path?
+
+ File.dirname(socket_path)
end
- def start_gitaly
+ # Linux fails with "bind: invalid argument" if a UNIX socket path exceeds 108 characters:
+ # https://github.com/golang/go/issues/6895. We use absolute paths in CI to ensure
+ # that changes in the current working directory don't affect GRPC reconnections.
+ def expand_path?
+ !!ENV['CI']
+ end
+
+ def start_gitaly(gitaly_dir)
if ci?
# Gitaly has been spawned outside this process already
return
end
- GitalySetup.spawn_gitaly
+ spawn_script = Rails.root.join('scripts/gitaly-test-spawn').to_s
+ Bundler.with_original_env do
+ unless system(spawn_script)
+ message = 'gitaly spawn failed'
+ message += " (try `rm -rf #{gitaly_dir}` ?)" unless ci?
+ raise message
+ end
+ end
+
+ gitaly_pid = Integer(File.read(TMP_TEST_PATH.join('gitaly.pid')))
+ gitaly2_pid = Integer(File.read(TMP_TEST_PATH.join('gitaly2.pid')))
+ praefect_pid = Integer(File.read(TMP_TEST_PATH.join('praefect.pid')))
+
+ Kernel.at_exit do
+ pids = [gitaly_pid, gitaly2_pid, praefect_pid]
+ pids.each { |pid| stop(pid) }
+ end
+
+ wait('gitaly')
+ wait('praefect')
+ end
+
+ def stop(pid)
+ Process.kill('KILL', pid)
+ rescue Errno::ESRCH
+ # The process can already be gone if the test run was INTerrupted.
end
def gitaly_url
ENV.fetch('GITALY_REPO_URL', nil)
end
+ def socket_path(service)
+ TMP_TEST_PATH.join('gitaly', "#{service}.socket").to_s
+ end
+
+ def praefect_socket_path
+ "unix:" + socket_path(:praefect)
+ end
+
+ def wait(service)
+ sleep_time = 10
+ sleep_interval = 0.1
+ socket = socket_path(service)
+
+ Integer(sleep_time / sleep_interval).times do
+ Socket.unix(socket)
+ return
+ rescue StandardError
+ sleep sleep_interval
+ end
+
+ raise "could not connect to #{service} at #{socket.inspect} after #{sleep_time} seconds"
+ end
+
# Feature specs are run through Workhorse
def setup_workhorse
# Always rebuild the config file
@@ -295,7 +378,8 @@ module TestEnv
def rm_storage_dir(storage, dir)
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
- target_repo_refs_path = File.join(GitalySetup.repos_path(storage), dir)
+ repos_path = Gitlab.config.repositories.storages[storage].legacy_disk_path
+ target_repo_refs_path = File.join(repos_path, dir)
FileUtils.remove_dir(target_repo_refs_path)
end
rescue Errno::ENOENT
@@ -303,7 +387,8 @@ module TestEnv
def storage_dir_exists?(storage, dir)
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
- File.exist?(File.join(GitalySetup.repos_path(storage), dir))
+ repos_path = Gitlab.config.repositories.storages[storage].legacy_disk_path
+ File.exist?(File.join(repos_path, dir))
end
end
@@ -316,7 +401,7 @@ module TestEnv
end
def repos_path
- @repos_path ||= GitalySetup.repos_path
+ @repos_path ||= Gitlab.config.repositories.storages[REPOS_STORAGE].legacy_disk_path
end
def backup_path
@@ -437,7 +522,7 @@ module TestEnv
end
end
- def component_timed_setup(component, install_dir:, version:, task:, fresh_install: true, task_args: [])
+ def component_timed_setup(component, install_dir:, version:, task:, task_args: [])
start = Time.now
ensure_component_dir_name_is_correct!(component, install_dir)
@@ -447,7 +532,7 @@ module TestEnv
if component_needs_update?(install_dir, version)
# Cleanup the component entirely to ensure we start fresh
- FileUtils.rm_rf(install_dir) if fresh_install
+ FileUtils.rm_rf(install_dir)
if ENV['SKIP_RAILS_ENV_IN_RAKE']
# When we run `scripts/setup-test-env`, we take care of loading the necessary dependencies
diff --git a/spec/support/praefect.rb b/spec/support/praefect.rb
index 451b47cc83c..3218275c2aa 100644
--- a/spec/support/praefect.rb
+++ b/spec/support/praefect.rb
@@ -1,11 +1,11 @@
# frozen_string_literal: true
-require_relative 'helpers/gitaly_setup'
+require_relative 'helpers/test_env'
RSpec.configure do |config|
config.before(:each, :praefect) do
allow(Gitlab.config.repositories.storages['default']).to receive(:[]).and_call_original
allow(Gitlab.config.repositories.storages['default']).to receive(:[]).with('gitaly_address')
- .and_return(GitalySetup.praefect_socket_path)
+ .and_return(TestEnv.praefect_socket_path)
end
end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index c6833531f62..27e9da3c6df 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -432,7 +432,7 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
.with(max_concurrency: 5, max_storage_concurrency: 2)
.and_call_original
end
- expect(::Backup::GitalyBackup).to receive(:new).with(anything, parallel: 5, parallel_storage: 2).and_call_original
+ expect(::Backup::GitalyBackup).to receive(:new).with(anything, max_parallelism: 5, storage_parallelism: 2).and_call_original
expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process
end
diff --git a/spec/workers/web_hook_worker_spec.rb b/spec/workers/web_hook_worker_spec.rb
index 0f40177eb7d..bbb8844a447 100644
--- a/spec/workers/web_hook_worker_spec.rb
+++ b/spec/workers/web_hook_worker_spec.rb
@@ -19,6 +19,15 @@ RSpec.describe WebHookWorker do
expect { subject.perform(non_existing_record_id, data, hook_name) }.not_to raise_error
end
+ it 'retrieves recursion detection data, reinstates it, and cleans it from payload', :request_store, :aggregate_failures do
+ uuid = SecureRandom.uuid
+ full_data = data.merge({ _gitlab_recursion_detection_request_uuid: uuid })
+
+ expect_next(WebHookService, project_hook, data.with_indifferent_access, hook_name, anything).to receive(:execute)
+ expect { subject.perform(project_hook.id, full_data, hook_name) }
+ .to change { Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid }.to(uuid)
+ end
+
it_behaves_like 'worker with data consistency',
described_class,
data_consistency: :delayed