Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-12-22 09:13:51 +00:00
parent 589ee0e419
commit 8bf2e2b73e
61 changed files with 604 additions and 348 deletions

View file

@ -0,0 +1,28 @@
import Vue from 'vue';
import StatusSelect from './components/status_select.vue';
import issuableBulkUpdateActions from './issuable_bulk_update_actions';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
export function initBulkUpdateSidebar(prefixId) {
const el = document.querySelector('.issues-bulk-update');
if (!el) {
return;
}
issuableBulkUpdateActions.init({ prefixId });
new IssuableBulkUpdateSidebar(); // eslint-disable-line no-new
}
export function initIssueStatusSelect() {
const el = document.querySelector('.js-issue-status');
if (!el) {
return null;
}
return new Vue({
el,
render: (createElement) => createElement(StatusSelect),
});
}

View file

@ -1,17 +0,0 @@
import Vue from 'vue';
import StatusSelect from './components/status_select.vue';
export default function initIssueStatusSelect() {
const el = document.querySelector('.js-issue-status');
if (!el) {
return null;
}
return new Vue({
el,
render(h) {
return h(StatusSelect);
},
});
}

View file

@ -6,7 +6,6 @@ import { property } from 'lodash';
import issuableEventHub from '~/issues_list/eventhub'; import issuableEventHub from '~/issues_list/eventhub';
import LabelsSelect from '~/labels/labels_select'; import LabelsSelect from '~/labels/labels_select';
import MilestoneSelect from '~/milestones/milestone_select'; import MilestoneSelect from '~/milestones/milestone_select';
import initIssueStatusSelect from './init_issue_status_select';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import subscriptionSelect from './subscription_select'; import subscriptionSelect from './subscription_select';
@ -57,7 +56,6 @@ export default class IssuableBulkUpdateSidebar {
initDropdowns() { initDropdowns() {
new LabelsSelect(); new LabelsSelect();
new MilestoneSelect(); new MilestoneSelect();
initIssueStatusSelect();
subscriptionSelect(); subscriptionSelect();
if (IS_EE) { if (IS_EE) {

View file

@ -1,19 +0,0 @@
import issuableBulkUpdateActions from './issuable_bulk_update_actions';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
export default {
bulkUpdateSidebar: null,
init(prefixId) {
const bulkUpdateEl = document.querySelector('.issues-bulk-update');
const alreadyInitialized = Boolean(this.bulkUpdateSidebar);
if (bulkUpdateEl && !alreadyInitialized) {
issuableBulkUpdateActions.init({ prefixId });
this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
}
return this.bulkUpdateSidebar;
},
};

View file

@ -11,7 +11,9 @@ import IssuableHeaderWarnings from './components/issuable_header_warnings.vue';
export function initCsvImportExportButtons() { export function initCsvImportExportButtons() {
const el = document.querySelector('.js-csv-import-export-buttons'); const el = document.querySelector('.js-csv-import-export-buttons');
if (!el) return null; if (!el) {
return null;
}
const { const {
showExportButton, showExportButton,
@ -42,23 +44,24 @@ export function initCsvImportExportButtons() {
maxAttachmentSize, maxAttachmentSize,
showLabel, showLabel,
}, },
render(h) { render: (createElement) =>
return h(CsvImportExportButtons, { createElement(CsvImportExportButtons, {
props: { props: {
exportCsvPath, exportCsvPath,
issuableCount: parseInt(issuableCount, 10), issuableCount: parseInt(issuableCount, 10),
}, },
}); }),
},
}); });
} }
export function initIssuableByEmail() { export function initIssuableByEmail() {
Vue.use(GlToast);
const el = document.querySelector('.js-issuable-by-email'); const el = document.querySelector('.js-issuable-by-email');
if (!el) return null; if (!el) {
return null;
}
Vue.use(GlToast);
const { const {
initialEmail, initialEmail,
@ -79,9 +82,7 @@ export function initIssuableByEmail() {
markdownHelpPath, markdownHelpPath,
resetPath, resetPath,
}, },
render(h) { render: (createElement) => createElement(IssuableByEmail),
return h(IssuableByEmail);
},
}); });
} }
@ -89,7 +90,7 @@ export function initIssuableHeaderWarnings(store) {
const el = document.getElementById('js-issuable-header-warnings'); const el = document.getElementById('js-issuable-header-warnings');
if (!el) { if (!el) {
return false; return null;
} }
const { hidden } = el.dataset; const { hidden } = el.dataset;
@ -98,18 +99,18 @@ export function initIssuableHeaderWarnings(store) {
el, el,
store, store,
provide: { hidden: parseBoolean(hidden) }, provide: { hidden: parseBoolean(hidden) },
render(createElement) { render: (createElement) => createElement(IssuableHeaderWarnings),
return createElement(IssuableHeaderWarnings);
},
}); });
} }
export function initIssuableSidebar() { export function initIssuableSidebar() {
const sidebarOptEl = document.querySelector('.js-sidebar-options'); const el = document.querySelector('.js-sidebar-options');
if (!sidebarOptEl) return; if (!el) {
return;
}
const sidebarOptions = getSidebarOptions(sidebarOptEl); const sidebarOptions = getSidebarOptions(el);
new IssuableContext(sidebarOptions.currentUser); // eslint-disable-line no-new new IssuableContext(sidebarOptions.currentUser); // eslint-disable-line no-new
Sidebar.initialize(); Sidebar.initialize();

View file

@ -517,10 +517,9 @@ export default {
}, },
async handleBulkUpdateClick() { async handleBulkUpdateClick() {
if (!this.hasInitBulkEdit) { if (!this.hasInitBulkEdit) {
const initBulkUpdateSidebar = await import( const bulkUpdateSidebar = await import('~/issuable/bulk_update_sidebar');
'~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar' bulkUpdateSidebar.initBulkUpdateSidebar('issuable_');
); bulkUpdateSidebar.initIssueStatusSelect();
initBulkUpdateSidebar.default.init('issuable_');
const usersSelect = await import('~/users_select'); const usersSelect = await import('~/users_select');
const UsersSelect = usersSelect.default; const UsersSelect = usersSelect.default;

View file

@ -172,8 +172,12 @@ export default class Todos {
updateBadges(data) { updateBadges(data) {
$(document).trigger('todo:toggle', data.count); $(document).trigger('todo:toggle', data.count);
document.querySelector('.js-todos-pending .badge').innerHTML = addDelimiter(data.count); document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter(
document.querySelector('.js-todos-done .badge').innerHTML = addDelimiter(data.done_count); data.count,
);
document.querySelector('.js-todos-done .js-todos-badge').innerHTML = addDelimiter(
data.done_count,
);
} }
goToTodoUrl(e) { goToTodoUrl(e) {

View file

@ -1,5 +1,5 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar';
import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list'; import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/issues/manual_ordering'; import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { FILTERED_SEARCH } from '~/filtered_search/constants';
@ -13,7 +13,7 @@ if (gon.features?.vueIssuesList) {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
IssuableFilteredSearchTokenKeys.removeTokensForKeys('release'); IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); initBulkUpdateSidebar(ISSUE_BULK_UPDATE_PREFIX);
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.ISSUES, page: FILTERED_SEARCH.ISSUES,

View file

@ -1,6 +1,6 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar';
import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
@ -8,7 +8,7 @@ import projectSelect from '~/project_select';
const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_';
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX); initBulkUpdateSidebar(ISSUABLE_BULK_UPDATE_PREFIX);
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,

View file

@ -1,7 +1,7 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar';
import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list'; import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/issues/manual_ordering'; import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { FILTERED_SEARCH } from '~/filtered_search/constants';
@ -20,7 +20,8 @@ if (gon.features?.vueIssuesList) {
useDefaultState: true, useDefaultState: true,
}); });
issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.ISSUE); initBulkUpdateSidebar(ISSUABLE_INDEX.ISSUE);
initIssueStatusSelect();
new UsersSelect(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new
initCsvImportExportButtons(); initCsvImportExportButtons();

View file

@ -2,13 +2,14 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar';
import { FILTERED_SEARCH } from '~/filtered_search/constants'; import { FILTERED_SEARCH } from '~/filtered_search/constants';
import { ISSUABLE_INDEX } from '~/issuable/constants'; import { ISSUABLE_INDEX } from '~/issuable/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import UsersSelect from '~/users_select'; import UsersSelect from '~/users_select';
issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.MERGE_REQUEST); initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST);
initIssueStatusSelect();
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration');

View file

@ -54,7 +54,7 @@ class GroupsFinder < UnionFinder
groups = [] groups = []
if current_user if current_user
if Feature.enabled?(:use_traversal_ids_groups_finder, default_enabled: :yaml) if Feature.enabled?(:use_traversal_ids_groups_finder, current_user, default_enabled: :yaml)
groups << current_user.authorized_groups.self_and_ancestors groups << current_user.authorized_groups.self_and_ancestors
groups << current_user.groups.self_and_descendants groups << current_user.groups.self_and_descendants
else else
@ -81,7 +81,7 @@ class GroupsFinder < UnionFinder
.groups .groups
.where('members.access_level >= ?', params[:min_access_level]) .where('members.access_level >= ?', params[:min_access_level])
if Feature.enabled?(:use_traversal_ids_groups_finder, default_enabled: :yaml) if Feature.enabled?(:use_traversal_ids_groups_finder, current_user, default_enabled: :yaml)
groups.self_and_descendants groups.self_and_descendants
else else
Gitlab::ObjectHierarchy Gitlab::ObjectHierarchy

View file

@ -2,15 +2,15 @@
module Admin module Admin
module BackgroundMigrationsHelper module BackgroundMigrationsHelper
def batched_migration_status_badge_class_name(migration) def batched_migration_status_badge_variant(migration)
class_names = { variants = {
'active' => 'badge-info', 'active' => :info,
'paused' => 'badge-warning', 'paused' => :warning,
'failed' => 'badge-danger', 'failed' => :danger,
'finished' => 'badge-success' 'finished' => :success
} }
class_names[migration.status] variants[migration.status]
end end
# The extra logic here is needed because total_tuple_count is just # The extra logic here is needed because total_tuple_count is just

View file

@ -42,16 +42,19 @@ module Ci
check_access!(build) check_access!(build)
new_build = clone_build(build) new_build = clone_build(build)
if create_deployment_in_separate_transaction?
new_build.run_after_commit do |new_build|
::Deployments::CreateForBuildService.new.execute(new_build)
end
end
::Ci::Pipelines::AddJobService.new(build.pipeline).execute!(new_build) do |job| ::Ci::Pipelines::AddJobService.new(build.pipeline).execute!(new_build) do |job|
BulkInsertableAssociations.with_bulk_insert do BulkInsertableAssociations.with_bulk_insert do
job.save! job.save!
end end
end end
if create_deployment_in_separate_transaction?
clone_deployment!(new_build, build)
end
build.reset # refresh the data to get new values of `retried` and `processed`. build.reset # refresh the data to get new values of `retried` and `processed`.
new_build new_build
@ -95,20 +98,6 @@ module Ci
.deployment_attributes_for(new_build, old_build.persisted_environment) .deployment_attributes_for(new_build, old_build.persisted_environment)
end end
def clone_deployment!(new_build, old_build)
return unless old_build.deployment.present?
# We should clone the previous deployment attributes instead of initializing
# new object with `Seed::Deployment`.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/347206
deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment
.new(new_build, old_build.persisted_environment).to_resource
return unless deployment
new_build.create_deployment!(deployment.attributes)
end
def create_deployment_in_separate_transaction? def create_deployment_in_separate_transaction?
strong_memoize(:create_deployment_in_separate_transaction) do strong_memoize(:create_deployment_in_separate_transaction) do
::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml) ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml)

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Deployments
# This class creates a deployment record for a build (a pipeline job).
class CreateForBuildService
DeploymentCreationError = Class.new(StandardError)
def execute(build)
return unless build.instance_of?(::Ci::Build) && build.persisted_environment.present?
# TODO: Move all buisness logic in `Seed::Deployment` to this class after
# `create_deployment_in_separate_transaction` feature flag has been removed.
# See https://gitlab.com/gitlab-org/gitlab/-/issues/348778
deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment
.new(build, build.persisted_environment).to_resource
return unless deployment
build.create_deployment!(deployment.attributes)
rescue ActiveRecord::RecordInvalid => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
DeploymentCreationError.new(e.message), build_id: build.id)
end
end
end

View file

@ -7,7 +7,7 @@
- else - else
= _('Unknown') = _('Unknown')
%td{ role: 'cell', data: { label: _('Status') } } %td{ role: 'cell', data: { label: _('Status') } }
%span.badge.badge-pill.gl-badge.sm{ class: batched_migration_status_badge_class_name(migration) }= migration.status.humanize = gl_badge_tag migration.status.humanize, { size: :sm, variant: batched_migration_status_badge_variant(migration) }
%td{ role: 'cell', data: { label: _('Action') } } %td{ role: 'cell', data: { label: _('Action') } }
- if migration.active? - if migration.active?
= button_to pause_admin_background_migration_path(migration), = button_to pause_admin_background_migration_path(migration),

View file

@ -8,18 +8,15 @@
%li.nav-item{ role: 'presentation' } %li.nav-item{ role: 'presentation' }
%a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path, class: (active_tab_classes if @current_tab == 'queued'), role: 'tab' } %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path, class: (active_tab_classes if @current_tab == 'queued'), role: 'tab' }
= _('Queued') = _('Queued')
%span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['queued'])
= limited_counter_with_delimiter(@relations_by_tab['queued'])
%li.nav-item{ role: 'presentation' } %li.nav-item{ role: 'presentation' }
%a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'failed'), class: (active_tab_classes if @current_tab == 'failed'), role: 'tab' } %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'failed'), class: (active_tab_classes if @current_tab == 'failed'), role: 'tab' }
= _('Failed') = _('Failed')
%span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['failed'])
= limited_counter_with_delimiter(@relations_by_tab['failed'])
%li.nav-item{ role: 'presentation' } %li.nav-item{ role: 'presentation' }
%a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'finished'), class: (active_tab_classes if @current_tab == 'finished'), role: 'tab' } %a.nav-link.gl-tab-nav-item{ href: admin_background_migrations_path(tab: 'finished'), class: (active_tab_classes if @current_tab == 'finished'), role: 'tab' }
= _('Finished') = _('Finished')
%span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = gl_tab_counter_badge limited_counter_with_delimiter(@relations_by_tab['finished'])
= limited_counter_with_delimiter(@relations_by_tab['finished'])
.tab-content.gl-tab-content .tab-content.gl-tab-content
.tab-pane.active{ role: 'tabpanel' } .tab-pane.active{ role: 'tabpanel' }

View file

@ -13,10 +13,10 @@
= gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do = gl_tabs_nav({ class: 'gl-flex-grow-1 gl-border-0' }) do
= gl_tab_link_to todos_filter_path(state: 'pending'), item_active: params[:state].blank? || params[:state] == 'pending', class: "js-todos-pending" do = gl_tab_link_to todos_filter_path(state: 'pending'), item_active: params[:state].blank? || params[:state] == 'pending', class: "js-todos-pending" do
= _("To Do") = _("To Do")
= gl_tab_counter_badge number_with_delimiter(todos_pending_count) = gl_tab_counter_badge(number_with_delimiter(todos_pending_count), { class: 'js-todos-badge' })
= gl_tab_link_to todos_filter_path(state: 'done'), item_active: params[:state] == 'done', class: "js-todos-done" do = gl_tab_link_to todos_filter_path(state: 'done'), item_active: params[:state] == 'done', class: "js-todos-done" do
= _("Done") = _("Done")
= gl_tab_counter_badge number_with_delimiter(todos_done_count) = gl_tab_counter_badge(number_with_delimiter(todos_done_count), { class: 'js-todos-badge' })
.nav-controls .nav-controls
- if @allowed_todos.any?(&:pending?) - if @allowed_todos.any?(&:pending?)

View file

@ -0,0 +1,25 @@
---
data_category: optional
key_path: redis_hll_counters.testing.users_clicking_license_testing_visiting_external_website_monthly
description: Count of users clicking licence to visit external information website
product_section: sec
product_stage: secure
product_group: group::static analysis
product_category: dependency_scanning
value_type: number
status: active
milestone: '14.7'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76917
time_frame: 28d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
options:
events:
- users_clicking_license_testing_visiting_external_website
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -0,0 +1,25 @@
---
data_category: optional
key_path: redis_hll_counters.testing.users_clicking_license_testing_visiting_external_website_weekly
description: Count of users clicking licence to visit external information website
product_section: sec
product_stage: secure
product_group: group::static analysis
product_category: dependency_scanning
value_type: number
status: active
milestone: '14.7'
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76917
time_frame: 7d
data_source: redis_hll
instrumentation_class: RedisHLLMetric
options:
events:
- users_clicking_license_testing_visiting_external_website
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate

View file

@ -23,7 +23,7 @@ Side effects:
## `ci_queueing_disaster_recovery_disable_quota` ## `ci_queueing_disaster_recovery_disable_quota`
This feature flag, if temporarily enabled, disables enforcing CI minutes quota This feature flag, if temporarily enabled, disables enforcing CI/CD minutes quota
on shared runners. This can help to reduce system resource usage on the on shared runners. This can help to reduce system resource usage on the
`jobs/request` endpoint by significantly reducing the computations being `jobs/request` endpoint by significantly reducing the computations being
performed. performed.

View file

@ -34,6 +34,7 @@ The following API resources are available in the project context:
| [Debian distributions](packages/debian_project_distributions.md) | `/projects/:id/debian_distributions` (also available for groups) | | [Debian distributions](packages/debian_project_distributions.md) | `/projects/:id/debian_distributions` (also available for groups) |
| [Dependencies](dependencies.md) **(ULTIMATE)** | `/projects/:id/dependencies` | | [Dependencies](dependencies.md) **(ULTIMATE)** | `/projects/:id/dependencies` |
| [Deploy keys](deploy_keys.md) | `/projects/:id/deploy_keys` (also available standalone) | | [Deploy keys](deploy_keys.md) | `/projects/:id/deploy_keys` (also available standalone) |
| [Deploy tokens](deploy_tokens.md) | `/projects/:id/deploy_tokens` (also available for groups and standalone) |
| [Deployments](deployments.md) | `/projects/:id/deployments` | | [Deployments](deployments.md) | `/projects/:id/deployments` |
| [Discussions](discussions.md) (threaded comments) | `/projects/:id/issues/.../discussions`, `/projects/:id/snippets/.../discussions`, `/projects/:id/merge_requests/.../discussions`, `/projects/:id/commits/.../discussions` (also available for groups) | | [Discussions](discussions.md) (threaded comments) | `/projects/:id/issues/.../discussions`, `/projects/:id/snippets/.../discussions`, `/projects/:id/merge_requests/.../discussions`, `/projects/:id/commits/.../discussions` (also available for groups) |
| [Environments](environments.md) | `/projects/:id/environments` | | [Environments](environments.md) | `/projects/:id/environments` |
@ -101,6 +102,7 @@ The following API resources are available in the group context:
| [Access requests](access_requests.md) | `/groups/:id/access_requests/` (also available for projects) | | [Access requests](access_requests.md) | `/groups/:id/access_requests/` (also available for projects) |
| [Custom attributes](custom_attributes.md) | `/groups/:id/custom_attributes` (also available for projects and users) | | [Custom attributes](custom_attributes.md) | `/groups/:id/custom_attributes` (also available for projects and users) |
| [Debian distributions](packages/debian_group_distributions.md) | `/groups/:id/-/packages/debian` (also available for projects) | | [Debian distributions](packages/debian_group_distributions.md) | `/groups/:id/-/packages/debian` (also available for projects) |
| [Deploy tokens](deploy_tokens.md) | `/groups/:id/deploy_tokens` (also available for projects and standalone) |
| [Discussions](discussions.md) (threaded comments) **(ULTIMATE)** | `/groups/:id/epics/.../discussions` (also available for projects) | | [Discussions](discussions.md) (threaded comments) **(ULTIMATE)** | `/groups/:id/epics/.../discussions` (also available for projects) |
| [Epic issues](epic_issues.md) **(ULTIMATE)** | `/groups/:id/epics/.../issues` | | [Epic issues](epic_issues.md) **(ULTIMATE)** | `/groups/:id/epics/.../issues` |
| [Epic links](epic_links.md) **(ULTIMATE)** | `/groups/:id/epics/.../epics` | | [Epic links](epic_links.md) **(ULTIMATE)** | `/groups/:id/epics/.../epics` |
@ -137,6 +139,7 @@ The following API resources are available outside of project and group contexts
| [Code snippets](snippets.md) | `/snippets` | | [Code snippets](snippets.md) | `/snippets` |
| [Custom attributes](custom_attributes.md) | `/users/:id/custom_attributes` (also available for groups and projects) | | [Custom attributes](custom_attributes.md) | `/users/:id/custom_attributes` (also available for groups and projects) |
| [Deploy keys](deploy_keys.md) | `/deploy_keys` (also available for projects) | | [Deploy keys](deploy_keys.md) | `/deploy_keys` (also available for projects) |
| [Deploy tokens](deploy_tokens.md) | `/deploy_tokens` (also available for projects and groups) |
| [Events](events.md) | `/events`, `/users/:id/events` (also available for projects) | | [Events](events.md) | `/events`, `/users/:id/events` (also available for projects) |
| [Feature flags](features.md) | `/features` | | [Feature flags](features.md) | `/features` |
| [Geo Nodes](geo_nodes.md) **(PREMIUM SELF)** | `/geo_nodes` | | [Geo Nodes](geo_nodes.md) **(PREMIUM SELF)** | `/geo_nodes` |

View file

@ -385,7 +385,7 @@ listed in the descriptions of the relevant settings.
| `send_user_confirmation_email` | boolean | no | Send confirmation email on sign-up. | | `send_user_confirmation_email` | boolean | no | Send confirmation email on sign-up. |
| `session_expire_delay` | integer | no | Session duration in minutes. GitLab restart is required to apply changes. | | `session_expire_delay` | integer | no | Session duration in minutes. GitLab restart is required to apply changes. |
| `shared_runners_enabled` | boolean | no | (**If enabled, requires:** `shared_runners_text` and `shared_runners_minutes`) Enable shared runners for new projects. | | `shared_runners_enabled` | boolean | no | (**If enabled, requires:** `shared_runners_text` and `shared_runners_minutes`) Enable shared runners for new projects. |
| `shared_runners_minutes` **(PREMIUM)** | integer | required by: `shared_runners_enabled` | Set the maximum number of pipeline minutes that a group can use on shared runners per month. | | `shared_runners_minutes` **(PREMIUM)** | integer | required by: `shared_runners_enabled` | Set the maximum number of CI/CD minutes that a group can use on shared runners per month. |
| `shared_runners_text` | string | required by: `shared_runners_enabled` | Shared runners text. | | `shared_runners_text` | string | required by: `shared_runners_enabled` | Shared runners text. |
| `sidekiq_job_limiter_mode` | string | no | `track` or `compress`. Sets the behavior for [Sidekiq job size limits](../user/admin_area/settings/sidekiq_job_limits.md). Default: 'compress'. | | `sidekiq_job_limiter_mode` | string | no | `track` or `compress`. Sets the behavior for [Sidekiq job size limits](../user/admin_area/settings/sidekiq_job_limits.md). Default: 'compress'. |
| `sidekiq_job_limiter_compression_threshold_bytes` | integer | no | The threshold in bytes at which Sidekiq jobs are compressed before being stored in Redis. Default: 100 000 bytes (100KB). | | `sidekiq_job_limiter_compression_threshold_bytes` | integer | no | The threshold in bytes at which Sidekiq jobs are compressed before being stored in Redis. Default: 100 000 bytes (100KB). |

View file

@ -257,14 +257,33 @@ WARNING:
Deleting a pipeline expires all pipeline caches, and deletes all related objects, Deleting a pipeline expires all pipeline caches, and deletes all related objects,
such as builds, logs, artifacts, and triggers. **This action cannot be undone.** such as builds, logs, artifacts, and triggers. **This action cannot be undone.**
### Quotas of CI/CD minutes ### Pipeline security on protected branches
Each user has a personal pipeline quota that tracks the usage of shared runners in all personal projects. A strict security model is enforced when pipelines are executed on
Each group has a [quota of CI/CD minutes](cicd_minutes.md) that tracks the usage of shared runners for all projects created within the group. [protected branches](../../user/project/protected_branches.md).
When a pipeline is triggered, regardless of who triggered it, the quota of CI/CD minutes for the project owner's [namespace](../../user/group/index.md#namespaces) is used. In this case, the namespace can be the user or group that owns the project. The following actions are allowed on protected branches only if the user is
[allowed to merge or push](../../user/project/protected_branches.md)
on that specific branch:
#### How pipeline duration is calculated - Run manual pipelines (using the [Web UI](#run-a-pipeline-manually) or [pipelines API](#pipelines-api)).
- Run scheduled pipelines.
- Run pipelines using triggers.
- Run on-demand DAST scan.
- Trigger manual actions on existing pipelines.
- Retry or cancel existing jobs (using the Web UI or pipelines API).
**Variables** marked as **protected** are accessible only to jobs that
run on protected branches, preventing untrusted users getting unintended access to
sensitive information like deployment credentials and tokens.
**Runners** marked as **protected** can run jobs only on protected
branches, preventing untrusted code from executing on the protected runner and
preserving deployment keys and other credentials from being unintentionally
accessed. In order to ensure that jobs intended to be executed on protected
runners do not use regular runners, they must be tagged accordingly.
### How pipeline duration is calculated
Total running time for a given pipeline excludes retries and pending Total running time for a given pipeline excludes retries and pending
(queued) time. (queued) time.
@ -301,44 +320,6 @@ The union of A, B, and C is (1, 4) and (6, 7). Therefore, the total running time
(4 - 1) + (7 - 6) => 4 (4 - 1) + (7 - 6) => 4
``` ```
#### How pipeline quota usage is calculated
Pipeline quota usage is calculated as the sum of the duration of each individual job. This is slightly different to how pipeline _duration_ is [calculated](#how-pipeline-duration-is-calculated). Pipeline quota usage doesn't consider any overlap of jobs running in parallel.
For example, a pipeline consists of the following jobs:
- Job A takes 3 minutes.
- Job B takes 3 minutes.
- Job C takes 2 minutes.
The pipeline quota usage is the sum of each job's duration. In this example, 8 runner minutes would be used, calculated as: 3 + 3 + 2.
### Pipeline security on protected branches
A strict security model is enforced when pipelines are executed on
[protected branches](../../user/project/protected_branches.md).
The following actions are allowed on protected branches only if the user is
[allowed to merge or push](../../user/project/protected_branches.md)
on that specific branch:
- Run manual pipelines (using the [Web UI](#run-a-pipeline-manually) or [pipelines API](#pipelines-api)).
- Run scheduled pipelines.
- Run pipelines using triggers.
- Run on-demand DAST scan.
- Trigger manual actions on existing pipelines.
- Retry or cancel existing jobs (using the Web UI or pipelines API).
**Variables** marked as **protected** are accessible only to jobs that
run on protected branches, preventing untrusted users getting unintended access to
sensitive information like deployment credentials and tokens.
**Runners** marked as **protected** can run jobs only on protected
branches, preventing untrusted code from executing on the protected runner and
preserving deployment keys and other credentials from being unintentionally
accessed. In order to ensure that jobs intended to be executed on protected
runners do not use regular runners, they must be tagged accordingly.
## Visualize pipelines ## Visualize pipelines
Pipelines can be complex structures with many sequential and parallel jobs. Pipelines can be complex structures with many sequential and parallel jobs.

View file

@ -28,7 +28,7 @@ If you are using a self-managed instance of GitLab:
going to your project's **Settings > CI/CD**, expanding the **Runners** section, going to your project's **Settings > CI/CD**, expanding the **Runners** section,
and clicking **Show runner installation instructions**. and clicking **Show runner installation instructions**.
These instructions are also available [in the documentation](https://docs.gitlab.com/runner/install/index.html). These instructions are also available [in the documentation](https://docs.gitlab.com/runner/install/index.html).
- The administrator can also configure a maximum number of shared runner [pipeline minutes for - The administrator can also configure a maximum number of shared runner [CI/CD minutes for
each group](../pipelines/cicd_minutes.md#set-the-quota-of-cicd-minutes-for-a-specific-namespace). each group](../pipelines/cicd_minutes.md#set-the-quota-of-cicd-minutes-for-a-specific-namespace).
If you are using GitLab.com: If you are using GitLab.com:

View file

@ -180,7 +180,7 @@ feature and its components work.
Watch a walkthrough of this feature in details in the video below. Watch a walkthrough of this feature in details in the video below.
<div class="video-fallback"> <div class="video-fallback">
See the video: <a href="https://www.youtube.com/watch?v=NmdWRGT8kZg">CI Minutes - architectural overview</a>. See the video: <a href="https://www.youtube.com/watch?v=NmdWRGT8kZg">CI/CD minutes - architectural overview</a>.
</div> </div>
<figure class="video-container"> <figure class="video-container">
<iframe src="https://www.youtube.com/embed/NmdWRGT8kZg" frameborder="0" allowfullscreen="true"> </iframe> <iframe src="https://www.youtube.com/embed/NmdWRGT8kZg" frameborder="0" allowfullscreen="true"> </iframe>

View file

@ -182,6 +182,11 @@ Use **check out** as a verb. For the Git command, use `checkout`.
CI/CD is always uppercase. No need to spell it out on first use. CI/CD is always uppercase. No need to spell it out on first use.
## CI/CD minutes
Use **CI/CD minutes** instead of **CI minutes**, **pipeline minutes**, **pipeline minutes quota**, or
**CI pipeline minutes**. This decision was made in [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/342813).
## click ## click
Do not use **click**. Instead, use **select** with buttons, links, menu items, and lists. Do not use **click**. Instead, use **select** with buttons, links, menu items, and lists.

View file

@ -703,10 +703,10 @@ Example response:
- CustomersDot - CustomersDot
## CI minute provisioning ## CI/CD minutes provisioning
The CI Minute endpoints are used by [CustomersDot](https://gitlab.com/gitlab-org/customers-gitlab-com) (`customers.gitlab.com`) The CI/CD Minutes endpoints are used by [CustomersDot](https://gitlab.com/gitlab-org/customers-gitlab-com) (`customers.gitlab.com`)
to apply additional packs of CI minutes, for personal namespaces or top-level groups within GitLab.com. to apply additional packs of CI/CD minutes, for personal namespaces or top-level groups within GitLab.com.
### Creating an additional pack ### Creating an additional pack

View file

@ -177,7 +177,7 @@ To change the password for this customers portal account:
### GitLab for Education ### GitLab for Education
For qualifying non-profit educational institutions, the [GitLab for Education](https://about.gitlab.com/solutions/education/) program provides For qualifying non-profit educational institutions, the [GitLab for Education](https://about.gitlab.com/solutions/education/) program provides
the top GitLab tier, plus 50,000 CI minutes per month. the top GitLab tier, plus 50,000 CI/CD minutes per month.
The GitLab for Education license can only be used for instructional-use or The GitLab for Education license can only be used for instructional-use or
non-commercial academic research. non-commercial academic research.
@ -188,7 +188,7 @@ Find more information on how to apply and renew at
### GitLab for Open Source ### GitLab for Open Source
For qualifying open source projects, the [GitLab for Open Source](https://about.gitlab.com/solutions/open-source/) program provides For qualifying open source projects, the [GitLab for Open Source](https://about.gitlab.com/solutions/open-source/) program provides
the top GitLab tier, plus 50,000 CI minutes per month. the top GitLab tier, plus 50,000 CI/CD minutes per month.
You can find more information about the [program requirements](https://about.gitlab.com/solutions/open-source/join/#requirements), You can find more information about the [program requirements](https://about.gitlab.com/solutions/open-source/join/#requirements),
[renewals](https://about.gitlab.com/solutions/open-source/join/#renewals), [renewals](https://about.gitlab.com/solutions/open-source/join/#renewals),
@ -253,7 +253,7 @@ if a project holds sensitive data. Email `opensource@gitlab.com` with details of
### GitLab for Startups ### GitLab for Startups
For qualifying startups, the [GitLab for Startups](https://about.gitlab.com/solutions/startups/) program provides For qualifying startups, the [GitLab for Startups](https://about.gitlab.com/solutions/startups/) program provides
the top GitLab tier, plus 50,000 CI minutes per month for 12 months. the top GitLab tier, plus 50,000 CI/CD minutes per month for 12 months.
For more information, including program requirements, see the [Startup program's landing page](https://about.gitlab.com/solutions/startups/). For more information, including program requirements, see the [Startup program's landing page](https://about.gitlab.com/solutions/startups/).

View file

@ -202,6 +202,8 @@ The following data is included in the export:
- Access level ([Project](../permissions.md#project-members-permissions) and [Group](../permissions.md#group-members-permissions)) - Access level ([Project](../permissions.md#project-members-permissions) and [Group](../permissions.md#group-members-permissions))
- Date of last activity ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345388) in GitLab 14.6). For a list of activities that populate this column, see the [Users API documentation](../../api/users.md#get-user-activities-admin-only). - Date of last activity ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345388) in GitLab 14.6). For a list of activities that populate this column, see the [Users API documentation](../../api/users.md#get-user-activities-admin-only).
Only the first 100,000 user accounts are exported.
![user permission export button](img/export_permissions_v13_11.png) ![user permission export button](img/export_permissions_v13_11.png)
#### Users statistics #### Users statistics

View file

@ -22,6 +22,10 @@ NOTE:
By default, all Git operations are first tried unauthenticated. Because of this, HTTP Git operations By default, all Git operations are first tried unauthenticated. Because of this, HTTP Git operations
may trigger the rate limits configured for unauthenticated requests. may trigger the rate limits configured for unauthenticated requests.
NOTE:
The rate limits for API requests don't affect requests made by the frontend, as these are always
counted as web traffic.
## Enable unauthenticated API request rate limit ## Enable unauthenticated API request rate limit
To enable the unauthenticated request rate limit: To enable the unauthenticated request rate limit:

View file

@ -774,7 +774,7 @@ To view the merge request approval rules for a group:
- [Webhooks](../project/integrations/webhooks.md). - [Webhooks](../project/integrations/webhooks.md).
- [Kubernetes cluster integration](clusters/index.md). - [Kubernetes cluster integration](clusters/index.md).
- [Audit Events](../../administration/audit_events.md#group-events). - [Audit Events](../../administration/audit_events.md#group-events).
- [Pipelines quota](../admin_area/settings/continuous_integration.md): Keep track of the pipeline quota for the group. - [CI/CD minutes quota](../../ci/pipelines/cicd_minutes.md): Keep track of the CI/CD minute quota for the group.
- [Integrations](../admin_area/settings/project_integration_management.md). - [Integrations](../admin_area/settings/project_integration_management.md).
- [Transfer a project into a group](../project/settings/index.md#transferring-an-existing-project-into-another-namespace). - [Transfer a project into a group](../project/settings/index.md#transferring-an-existing-project-into-another-namespace).
- [Share a project with a group](../project/members/share_project_with_groups.md): Give all group members access to the project at once. - [Share a project with a group](../project/members/share_project_with_groups.md): Give all group members access to the project at once.

View file

@ -207,7 +207,7 @@ This improvement is [tracked as a follow-up](https://gitlab.com/gitlab-org/gitla
- When working locally in your branch, add multiple commits and only push when - When working locally in your branch, add multiple commits and only push when
you're done, so GitLab runs only one pipeline for all the commits pushed you're done, so GitLab runs only one pipeline for all the commits pushed
at once. By doing so, you save pipeline minutes. at once. By doing so, you save CI/CD minutes.
- Delete feature branches on merge or after merging them to keep your repository clean. - Delete feature branches on merge or after merging them to keep your repository clean.
- Take one thing at a time and ship the smallest changes possible. By doing so, - Take one thing at a time and ship the smallest changes possible. By doing so,
reviews are faster and your changes are less prone to errors. reviews are faster and your changes are less prone to errors.

View file

@ -5,8 +5,6 @@ module Gitlab
module Pipeline module Pipeline
module Chain module Chain
class CreateDeployments < Chain::Base class CreateDeployments < Chain::Base
DeploymentCreationError = Class.new(StandardError)
def perform! def perform!
return unless pipeline.create_deployment_in_separate_transaction? return unless pipeline.create_deployment_in_separate_transaction?
@ -24,18 +22,7 @@ module Gitlab
end end
def create_deployment(build) def create_deployment(build)
return unless build.instance_of?(::Ci::Build) && build.persisted_environment.present? ::Deployments::CreateForBuildService.new.execute(build)
deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment
.new(build, build.persisted_environment).to_resource
return unless deployment
deployment.deployable = build
deployment.save!
rescue ActiveRecord::RecordInvalid => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
DeploymentCreationError.new(e.message), build_id: build.id)
end end
end end
end end

View file

@ -3,6 +3,8 @@
module Gitlab module Gitlab
module RackAttack module RackAttack
module Request module Request
include ::Gitlab::Utils::StrongMemoize
FILES_PATH_REGEX = %r{^/api/v\d+/projects/[^/]+/repository/files/.+}.freeze FILES_PATH_REGEX = %r{^/api/v\d+/projects/[^/]+/repository/files/.+}.freeze
GROUP_PATH_REGEX = %r{^/api/v\d+/groups/[^/]+/?$}.freeze GROUP_PATH_REGEX = %r{^/api/v\d+/groups/[^/]+/?$}.freeze
@ -30,15 +32,15 @@ module Gitlab
end end
def api_internal_request? def api_internal_request?
path =~ %r{^/api/v\d+/internal/} path.match?(%r{^/api/v\d+/internal/})
end end
def health_check_request? def health_check_request?
path =~ %r{^/-/(health|liveness|readiness|metrics)} path.match?(%r{^/-/(health|liveness|readiness|metrics)})
end end
def container_registry_event? def container_registry_event?
path =~ %r{^/api/v\d+/container_registry_event/} path.match?(%r{^/api/v\d+/container_registry_event/})
end end
def product_analytics_collector_request? def product_analytics_collector_request?
@ -58,7 +60,7 @@ module Gitlab
end end
def protected_path_regex def protected_path_regex
path =~ protected_paths_regex path.match?(protected_paths_regex)
end end
def throttle?(throttle, authenticated:) def throttle?(throttle, authenticated:)
@ -70,6 +72,7 @@ module Gitlab
def throttle_unauthenticated_api? def throttle_unauthenticated_api?
api_request? && api_request? &&
!should_be_skipped? && !should_be_skipped? &&
!frontend_request? &&
!throttle_unauthenticated_packages_api? && !throttle_unauthenticated_packages_api? &&
!throttle_unauthenticated_files_api? && !throttle_unauthenticated_files_api? &&
!throttle_unauthenticated_deprecated_api? && !throttle_unauthenticated_deprecated_api? &&
@ -78,7 +81,7 @@ module Gitlab
end end
def throttle_unauthenticated_web? def throttle_unauthenticated_web?
web_request? && (web_request? || frontend_request?) &&
!should_be_skipped? && !should_be_skipped? &&
# TODO: Column will be renamed in https://gitlab.com/gitlab-org/gitlab/-/issues/340031 # TODO: Column will be renamed in https://gitlab.com/gitlab-org/gitlab/-/issues/340031
Gitlab::Throttle.settings.throttle_unauthenticated_enabled && Gitlab::Throttle.settings.throttle_unauthenticated_enabled &&
@ -87,6 +90,7 @@ module Gitlab
def throttle_authenticated_api? def throttle_authenticated_api?
api_request? && api_request? &&
!frontend_request? &&
!throttle_authenticated_packages_api? && !throttle_authenticated_packages_api? &&
!throttle_authenticated_files_api? && !throttle_authenticated_files_api? &&
!throttle_authenticated_deprecated_api? && !throttle_authenticated_deprecated_api? &&
@ -94,7 +98,7 @@ module Gitlab
end end
def throttle_authenticated_web? def throttle_authenticated_web?
web_request? && (web_request? || frontend_request?) &&
!throttle_authenticated_git_lfs? && !throttle_authenticated_git_lfs? &&
Gitlab::Throttle.settings.throttle_authenticated_web_enabled Gitlab::Throttle.settings.throttle_authenticated_web_enabled
end end
@ -178,15 +182,24 @@ module Gitlab
end end
def packages_api_path? def packages_api_path?
path =~ ::Gitlab::Regex::Packages::API_PATH_REGEX path.match?(::Gitlab::Regex::Packages::API_PATH_REGEX)
end end
def git_lfs_path? def git_lfs_path?
path =~ Gitlab::PathRegex.repository_git_lfs_route_regex path.match?(Gitlab::PathRegex.repository_git_lfs_route_regex)
end end
def files_api_path? def files_api_path?
path =~ FILES_PATH_REGEX path.match?(FILES_PATH_REGEX)
end
def frontend_request?
strong_memoize(:frontend_request) do
next false unless env.include?('HTTP_X_CSRF_TOKEN') && session.include?(:_csrf_token)
# CSRF tokens are not verified for GET/HEAD requests, so we pretend that we always have a POST request.
Gitlab::RequestForgeryProtection.verified?(env.merge('REQUEST_METHOD' => 'POST'))
end
end end
def deprecated_api_request? def deprecated_api_request?
@ -195,7 +208,7 @@ module Gitlab
with_projects = params['with_projects'] with_projects = params['with_projects']
with_projects = true if with_projects.blank? with_projects = true if with_projects.blank?
path =~ GROUP_PATH_REGEX && Gitlab::Utils.to_boolean(with_projects) path.match?(GROUP_PATH_REGEX) && Gitlab::Utils.to_boolean(with_projects)
end end
end end
end end

View file

@ -368,6 +368,10 @@
redis_slot: testing redis_slot: testing
category: testing category: testing
aggregation: weekly aggregation: weekly
- name: users_clicking_license_testing_visiting_external_website
redis_slot: testing
category: testing
aggregation: weekly
# Container Security - Network Policies # Container Security - Network Policies
- name: clusters_using_network_policies_ui - name: clusters_using_network_policies_ui
redis_slot: network_policies redis_slot: network_policies

View file

@ -2768,7 +2768,7 @@ msgstr ""
msgid "AdminUsers|Delete user and contributions" msgid "AdminUsers|Delete user and contributions"
msgstr "" msgstr ""
msgid "AdminUsers|Export permissions as CSV" msgid "AdminUsers|Export permissions as CSV (max 100,000 users)"
msgstr "" msgstr ""
msgid "AdminUsers|External" msgid "AdminUsers|External"

View file

@ -179,6 +179,30 @@ module QA
QA::Support::Waiter.wait_until(max_duration: max_duration, sleep_interval: sleep_interval, &block) QA::Support::Waiter.wait_until(max_duration: max_duration, sleep_interval: sleep_interval, &block)
end end
# Object comparison
#
# @param [QA::Resource::Base] other
# @return [Boolean]
def ==(other)
other.is_a?(self.class) && comparable == other.comparable
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable)
end
protected
# Custom resource comparison logic using resource attributes from api_resource
#
# @return [Hash]
def comparable
raise("comparable method needs to be implemented in order to compare resources via '=='")
end
private private
def attribute_value(name, block) def attribute_value(name, block)

View file

@ -39,27 +39,12 @@ module QA
# @return [String] # @return [String]
def resource_web_url(_resource); end def resource_web_url(_resource); end
# Object comparison
#
# @param [QA::Resource::GroupBadge] other
# @return [Boolean]
def ==(other)
other.is_a?(GroupBadge) && comparable_badge == other.comparable_badge
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable_badge)
end
protected protected
# Return subset of fields for comparing badges # Return subset of fields for comparing badges
# #
# @return [Hash] # @return [Hash]
def comparable_badge def comparable
reload! unless api_response reload! unless api_response
api_response.slice( api_response.slice(

View file

@ -123,18 +123,12 @@ module QA
end end
# Object comparison # Object comparison
# Override to make sure we are comparing descendands of GroupBase
# #
# @param [QA::Resource::GroupBase] other # @param [QA::Resource::GroupBase] other
# @return [Boolean] # @return [Boolean]
def ==(other) def ==(other)
other.is_a?(GroupBase) && comparable_group == other.comparable_group other.is_a?(GroupBase) && comparable == other.comparable
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable_group)
end end
protected protected
@ -142,7 +136,7 @@ module QA
# Return subset of fields for comparing groups # Return subset of fields for comparing groups
# #
# @return [Hash] # @return [Hash]
def comparable_group def comparable
reload! if api_response.nil? reload! if api_response.nil?
api_resource.slice( api_resource.slice(

View file

@ -56,27 +56,12 @@ module QA
end end
end end
# Object comparison
#
# @param [QA::Resource::GroupMilestone] other
# @return [Boolean]
def ==(other)
other.is_a?(GroupMilestone) && comparable_milestone == other.comparable_milestone
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable_milestone)
end
protected protected
# Return subset of fields for comparing milestones # Return subset of fields for comparing milestones
# #
# @return [Hash] # @return [Hash]
def comparable_milestone def comparable
reload! unless api_response reload! unless api_response
api_response.slice( api_response.slice(

View file

@ -95,27 +95,12 @@ module QA
) )
end end
# Object comparison
#
# @param [QA::Resource::Issue] other
# @return [Boolean]
def ==(other)
other.is_a?(Issue) && comparable_issue == other.comparable_issue
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable_issue)
end
protected protected
# Return subset of fields for comparing issues # Return subset of fields for comparing issues
# #
# @return [Hash] # @return [Hash]
def comparable_issue def comparable
reload! if api_response.nil? reload! if api_response.nil?
api_resource.slice( api_resource.slice(

View file

@ -49,27 +49,12 @@ module QA
} }
end end
# Object comparison
#
# @param [QA::Resource::GroupBase] other
# @return [Boolean]
def ==(other)
other.is_a?(LabelBase) && comparable_label == other.comparable_label
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable_label)
end
protected protected
# Return subset of fields for comparing labels # Return subset of fields for comparing labels
# #
# @return [Hash] # @return [Hash]
def comparable_label def comparable
reload! unless api_response reload! unless api_response
api_response.slice( api_response.slice(

View file

@ -168,27 +168,12 @@ module QA
) )
end end
# Object comparison
#
# @param [QA::Resource::MergeRequest] other
# @return [Boolean]
def ==(other)
other.is_a?(MergeRequest) && comparable_mr == other.comparable_mr
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable_mr)
end
protected protected
# Return subset of fields for comparing merge requests # Return subset of fields for comparing merge requests
# #
# @return [Hash] # @return [Hash]
def comparable_mr def comparable
reload! if api_response.nil? reload! if api_response.nil?
api_resource.except( api_resource.except(

View file

@ -372,27 +372,12 @@ module QA
api_post_to(api_wikis_path, title: title, content: content) api_post_to(api_wikis_path, title: title, content: content)
end end
# Object comparison
#
# @param [QA::Resource::Project] other
# @return [Boolean]
def ==(other)
other.is_a?(Project) && comparable_project == other.comparable_project
end
# Override inspect for a better rspec failure diff output
#
# @return [String]
def inspect
JSON.pretty_generate(comparable_project)
end
protected protected
# Return subset of fields for comparing projects # Return subset of fields for comparing projects
# #
# @return [Hash] # @return [Hash]
def comparable_project def comparable
reload! if api_response.nil? reload! if api_response.nil?
api_resource.slice( api_resource.slice(

View file

@ -181,6 +181,15 @@ module QA
) )
end end
protected
# Compare users by username and password
#
# @return [Array]
def comparable
[username, password]
end
private private
def ldap_post_body def ldap_post_body

View file

@ -84,7 +84,8 @@ module QA
retry_attempts: example.metadata[:retry_attempts] || 0, retry_attempts: example.metadata[:retry_attempts] || 0,
job_url: QA::Runtime::Env.ci_job_url, job_url: QA::Runtime::Env.ci_job_url,
pipeline_url: env('CI_PIPELINE_URL'), pipeline_url: env('CI_PIPELINE_URL'),
pipeline_id: env('CI_PIPELINE_ID') pipeline_id: env('CI_PIPELINE_ID'),
testcase: example.metadata[:testcase]
} }
} }
rescue StandardError => e rescue StandardError => e

View file

@ -56,13 +56,14 @@ describe QA::Support::Formatters::TestStatsFormatter do
retry_attempts: 0, retry_attempts: 0,
job_url: ci_job_url, job_url: ci_job_url,
pipeline_url: ci_pipeline_url, pipeline_url: ci_pipeline_url,
pipeline_id: ci_pipeline_id pipeline_id: ci_pipeline_id,
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/1234'
} }
} }
end end
def run_spec(&spec) def run_spec(&spec)
spec ||= -> { it('spec') {} } spec ||= -> { it('spec', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/1234') {} }
describe_successfully('stats export', &spec).tap do |example_group| describe_successfully('stats export', &spec).tap do |example_group|
example_group.examples.each { |ex| ex.metadata[:file_path] = file_path } example_group.examples.each { |ex| ex.metadata[:file_path] = file_path }
@ -131,7 +132,7 @@ describe QA::Support::Formatters::TestStatsFormatter do
it 'exports data to influxdb with correct reliable tag' do it 'exports data to influxdb with correct reliable tag' do
run_spec do run_spec do
it('spec', :reliable) {} it('spec', :reliable, testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/1234') {}
end end
expect(influx_write_api).to have_received(:write).with(data: [data]) expect(influx_write_api).to have_received(:write).with(data: [data])
@ -143,7 +144,7 @@ describe QA::Support::Formatters::TestStatsFormatter do
it 'exports data to influxdb with correct quarantine tag' do it 'exports data to influxdb with correct quarantine tag' do
run_spec do run_spec do
it('spec', :quarantine) {} it('spec', :quarantine, testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/1234') {}
end end
expect(influx_write_api).to have_received(:write).with(data: [data]) expect(influx_write_api).to have_received(:write).with(data: [data])

View file

@ -3,15 +3,11 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe ApplicationCable::Connection, :clean_gitlab_redis_sessions do RSpec.describe ApplicationCable::Connection, :clean_gitlab_redis_sessions do
let(:session_id) { Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') } include SessionHelpers
context 'when session cookie is set' do context 'when session cookie is set' do
before do before do
Gitlab::Redis::Sessions.with do |redis| stub_session(session_hash)
redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
end
cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
end end
context 'when user is logged in' do context 'when user is logged in' do

View file

@ -94,13 +94,13 @@ describe('Todos', () => {
}); });
it('updates pending text', () => { it('updates pending text', () => {
expect(document.querySelector('.js-todos-pending .badge').innerHTML).toEqual( expect(document.querySelector('.js-todos-pending .js-todos-badge').innerHTML).toEqual(
addDelimiter(TEST_COUNT_BIG), addDelimiter(TEST_COUNT_BIG),
); );
}); });
it('updates done text', () => { it('updates done text', () => {
expect(document.querySelector('.js-todos-done .badge').innerHTML).toEqual( expect(document.querySelector('.js-todos-done .js-todos-badge').innerHTML).toEqual(
addDelimiter(TEST_DONE_COUNT_BIG), addDelimiter(TEST_DONE_COUNT_BIG),
); );
}); });

View file

@ -3,22 +3,22 @@
require "spec_helper" require "spec_helper"
RSpec.describe Admin::BackgroundMigrationsHelper do RSpec.describe Admin::BackgroundMigrationsHelper do
describe '#batched_migration_status_badge_class_name' do describe '#batched_migration_status_badge_variant' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
where(:status, :class_name) do where(:status, :variant) do
:active | 'badge-info' :active | :info
:paused | 'badge-warning' :paused | :warning
:failed | 'badge-danger' :failed | :danger
:finished | 'badge-success' :finished | :success
end end
subject { helper.batched_migration_status_badge_class_name(migration) } subject { helper.batched_migration_status_badge_variant(migration) }
with_them do with_them do
let(:migration) { build(:batched_background_migration, status: status) } let(:migration) { build(:batched_background_migration, status: status) }
it { is_expected.to eq(class_name) } it { is_expected.to eq(variant) }
end end
end end

View file

@ -38,20 +38,6 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::CreateDeployments do
expect(job.deployment.environment).to eq(job.persisted_environment) expect(job.deployment.environment).to eq(job.persisted_environment)
end end
context 'when creation failure occures' do
before do
allow_next_instance_of(Deployment) do |deployment|
allow(deployment).to receive(:save!) { raise ActiveRecord::RecordInvalid }
end
end
it 'trackes the exception' do
expect { subject }.to raise_error(described_class::DeploymentCreationError)
expect(Deployment.count).to eq(0)
end
end
context 'when the corresponding environment does not exist' do context 'when the corresponding environment does not exist' do
let!(:environment) { } let!(:environment) { }

View file

@ -5,6 +5,19 @@ require 'spec_helper'
RSpec.describe Gitlab::RackAttack::Request do RSpec.describe Gitlab::RackAttack::Request do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
let(:env) { {} }
let(:session) { {} }
let(:request) do
::Rack::Attack::Request.new(
env.reverse_merge(
'REQUEST_METHOD' => 'GET',
'PATH_INFO' => path,
'rack.input' => StringIO.new,
'rack.session' => session
)
)
end
describe 'FILES_PATH_REGEX' do describe 'FILES_PATH_REGEX' do
subject { described_class::FILES_PATH_REGEX } subject { described_class::FILES_PATH_REGEX }
@ -16,11 +29,63 @@ RSpec.describe Gitlab::RackAttack::Request do
it { is_expected.not_to match('/api/v4/projects/some/nested/repo/repository/files/README') } it { is_expected.not_to match('/api/v4/projects/some/nested/repo/repository/files/README') }
end end
describe '#deprecated_api_request?' do describe '#api_request?' do
let(:env) { { 'REQUEST_METHOD' => 'GET', 'rack.input' => StringIO.new, 'PATH_INFO' => path, 'QUERY_STRING' => query } } subject { request.api_request? }
let(:request) { ::Rack::Attack::Request.new(env) }
subject { !!request.__send__(:deprecated_api_request?) } where(:path, :env, :expected) do
'/' | {} | false
'/groups' | {} | false
'/api' | {} | true
'/api/v4/groups/1' | {} | true
end
with_them do
it { is_expected.to eq(expected) }
end
end
describe '#web_request?' do
subject { request.web_request? }
where(:path, :env, :expected) do
'/' | {} | true
'/groups' | {} | true
'/api' | {} | false
'/api/v4/groups/1' | {} | false
end
with_them do
it { is_expected.to eq(expected) }
end
end
describe '#frontend_request?', :allow_forgery_protection do
subject { request.send(:frontend_request?) }
let(:path) { '/' }
# Define these as local variables so we can use them in the `where` block.
valid_token = SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH)
other_token = SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH)
where(:session, :env, :expected) do
{} | {} | false # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
{} | { 'HTTP_X_CSRF_TOKEN' => valid_token } | false
{ _csrf_token: valid_token } | { 'HTTP_X_CSRF_TOKEN' => other_token } | false
{ _csrf_token: valid_token } | { 'HTTP_X_CSRF_TOKEN' => valid_token } | true
end
with_them do
it { is_expected.to eq(expected) }
end
end
describe '#deprecated_api_request?' do
subject { request.send(:deprecated_api_request?) }
let(:env) { { 'QUERY_STRING' => query } }
where(:path, :query, :expected) do where(:path, :query, :expected) do
'/' | '' | false '/' | '' | false

View file

@ -2064,6 +2064,31 @@ RSpec.describe Ci::Build do
end end
describe 'build auto retry feature' do describe 'build auto retry feature' do
context 'with deployment job' do
let(:build) do
create(:ci_build, :deploy_to_production, :with_deployment,
user: user, pipeline: pipeline, project: project)
end
before do
project.add_developer(user)
allow(build).to receive(:auto_retry_allowed?) { true }
end
it 'creates a deployment when a build is dropped' do
expect { build.drop!(:script_failure) }.to change { Deployment.count }.by(1)
retried_deployment = Deployment.last
expect(build.deployment.environment).to eq(retried_deployment.environment)
expect(build.deployment.ref).to eq(retried_deployment.ref)
expect(build.deployment.sha).to eq(retried_deployment.sha)
expect(build.deployment.tag).to eq(retried_deployment.tag)
expect(build.deployment.user).to eq(retried_deployment.user)
expect(build.deployment).to be_failed
expect(retried_deployment).to be_created
end
end
describe '#retries_count' do describe '#retries_count' do
subject { create(:ci_build, name: 'test', pipeline: pipeline) } subject { create(:ci_build, name: 'test', pipeline: pipeline) }

View file

@ -5,6 +5,7 @@ require 'mime/types'
RSpec.describe API::Commits do RSpec.describe API::Commits do
include ProjectForksHelper include ProjectForksHelper
include SessionHelpers
let(:user) { create(:user) } let(:user) { create(:user) }
let(:guest) { create(:user).tap { |u| project.add_guest(u) } } let(:guest) { create(:user).tap { |u| project.add_guest(u) } }
@ -378,14 +379,7 @@ RSpec.describe API::Commits do
context 'when using warden' do context 'when using warden' do
it 'increments usage counters', :clean_gitlab_redis_sessions do it 'increments usage counters', :clean_gitlab_redis_sessions do
session_id = Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') stub_session('warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]])
session_hash = { 'warden.user.user.key' => [[user.id], user.encrypted_password[0, 29]] }
Gitlab::Redis::Sessions.with do |redis|
redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
end
cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
expect(::Gitlab::UsageDataCounters::WebIdeCounter).to receive(:increment_commits_count) expect(::Gitlab::UsageDataCounters::WebIdeCounter).to receive(:increment_commits_count)
expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).to receive(:track_web_ide_edit_action) expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).to receive(:track_web_ide_edit_action)

View file

@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_caching do RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_caching do
include RackAttackSpecHelpers include RackAttackSpecHelpers
include SessionHelpers
let(:settings) { Gitlab::CurrentSettings.current_application_settings } let(:settings) { Gitlab::CurrentSettings.current_application_settings }
@ -63,6 +64,22 @@ RSpec.describe 'Rack Attack global throttles', :use_clean_rails_memory_store_cac
end end
end end
describe 'API requests from the frontend', :api, :clean_gitlab_redis_sessions do
context 'when unauthenticated' do
it_behaves_like 'rate-limited frontend API requests' do
let(:throttle_setting_prefix) { 'throttle_unauthenticated' }
end
end
context 'when authenticated' do
it_behaves_like 'rate-limited frontend API requests' do
let_it_be(:personal_access_token) { create(:personal_access_token) }
let(:throttle_setting_prefix) { 'throttle_authenticated' }
end
end
end
describe 'API requests authenticated with personal access token', :api do describe 'API requests authenticated with personal access token', :api do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:token) { create(:personal_access_token, user: user) } let_it_be(:token) { create(:personal_access_token, user: user) }

View file

@ -0,0 +1,82 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Deployments::CreateForBuildService do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let(:service) { described_class.new }
describe '#execute' do
subject { service.execute(build) }
context 'with a deployment job' do
let!(:build) { create(:ci_build, :start_review_app, project: project) }
let!(:environment) { create(:environment, project: project, name: build.expanded_environment_name) }
it 'creates a deployment record' do
expect { subject }.to change { Deployment.count }.by(1)
build.reset
expect(build.deployment.project).to eq(build.project)
expect(build.deployment.ref).to eq(build.ref)
expect(build.deployment.sha).to eq(build.sha)
expect(build.deployment.deployable).to eq(build)
expect(build.deployment.deployable_type).to eq('CommitStatus')
expect(build.deployment.environment).to eq(build.persisted_environment)
end
context 'when creation failure occures' do
before do
allow(build).to receive(:create_deployment!) { raise ActiveRecord::RecordInvalid }
end
it 'trackes the exception' do
expect { subject }.to raise_error(described_class::DeploymentCreationError)
expect(Deployment.count).to eq(0)
end
end
context 'when the corresponding environment does not exist' do
let!(:environment) { }
it 'does not create a deployment record' do
expect { subject }.not_to change { Deployment.count }
expect(build.deployment).to be_nil
end
end
end
context 'with a teardown job' do
let!(:build) { create(:ci_build, :stop_review_app, project: project) }
let!(:environment) { create(:environment, name: build.expanded_environment_name) }
it 'does not create a deployment record' do
expect { subject }.not_to change { Deployment.count }
expect(build.deployment).to be_nil
end
end
context 'with a normal job' do
let!(:build) { create(:ci_build, project: project) }
it 'does not create a deployment record' do
expect { subject }.not_to change { Deployment.count }
expect(build.deployment).to be_nil
end
end
context 'with a bridge' do
let!(:build) { create(:ci_bridge, project: project) }
it 'does not create a deployment record' do
expect { subject }.not_to change { Deployment.count }
end
end
end
end

View file

@ -26,14 +26,14 @@ module RackAttackSpecHelpers
{ 'AUTHORIZATION' => "Basic #{encoded_login}" } { 'AUTHORIZATION' => "Basic #{encoded_login}" }
end end
def expect_rejection(&block) def expect_rejection(name = nil, &block)
yield yield
expect(response).to have_gitlab_http_status(:too_many_requests) expect(response).to have_gitlab_http_status(:too_many_requests)
expect(response.headers.to_h).to include( expect(response.headers.to_h).to include(
'RateLimit-Limit' => a_string_matching(/^\d+$/), 'RateLimit-Limit' => a_string_matching(/^\d+$/),
'RateLimit-Name' => a_string_matching(/^throttle_.*$/), 'RateLimit-Name' => name || a_string_matching(/^throttle_.*$/),
'RateLimit-Observed' => a_string_matching(/^\d+$/), 'RateLimit-Observed' => a_string_matching(/^\d+$/),
'RateLimit-Remaining' => a_string_matching(/^\d+$/), 'RateLimit-Remaining' => a_string_matching(/^\d+$/),
'Retry-After' => a_string_matching(/^\d+$/) 'Retry-After' => a_string_matching(/^\d+$/)

View file

@ -1,6 +1,22 @@
# frozen_string_literal: true # frozen_string_literal: true
module SessionHelpers module SessionHelpers
# Stub a session in Redis, for use in request specs where we can't mock the session directly.
# This also needs the :clean_gitlab_redis_sessions tag on the spec.
def stub_session(session_hash)
unless RSpec.current_example.metadata[:clean_gitlab_redis_sessions]
raise 'Add :clean_gitlab_redis_sessions to your spec!'
end
session_id = Rack::Session::SessionId.new(SecureRandom.hex)
Gitlab::Redis::Sessions.with do |redis|
redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
end
cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
end
def expect_single_session_with_authenticated_ttl def expect_single_session_with_authenticated_ttl
expect_single_session_with_expiration(Settings.gitlab['session_expire_delay'] * 60) expect_single_session_with_expiration(Settings.gitlab['session_expire_delay'] * 60)
end end

View file

@ -10,6 +10,8 @@ RSpec.shared_examples 'when the snippet is not found' do
end end
RSpec.shared_examples 'snippet edit usage data counters' do RSpec.shared_examples 'snippet edit usage data counters' do
include SessionHelpers
context 'when user is sessionless' do context 'when user is sessionless' do
it 'does not track usage data actions' do it 'does not track usage data actions' do
expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_snippet_editor_edit_action) expect(::Gitlab::UsageDataCounters::EditorUniqueCounter).not_to receive(:track_snippet_editor_edit_action)
@ -20,14 +22,7 @@ RSpec.shared_examples 'snippet edit usage data counters' do
context 'when user is not sessionless', :clean_gitlab_redis_sessions do context 'when user is not sessionless', :clean_gitlab_redis_sessions do
before do before do
session_id = Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') stub_session('warden.user.user.key' => [[current_user.id], current_user.encrypted_password[0, 29]])
session_hash = { 'warden.user.user.key' => [[current_user.id], current_user.encrypted_password[0, 29]] }
Gitlab::Redis::Sessions.with do |redis|
redis.set("session:gitlab:#{session_id.private_id}", Marshal.dump(session_hash))
end
cookies[Gitlab::Application.config.session_options[:key]] = session_id.public_id
end end
it 'tracks usage data actions', :clean_gitlab_redis_sessions do it 'tracks usage data actions', :clean_gitlab_redis_sessions do

View file

@ -580,3 +580,88 @@ RSpec.shared_examples 'rate-limited unauthenticated requests' do
end end
end end
end end
# Requires let variables:
# * throttle_setting_prefix: "throttle_authenticated", "throttle_unauthenticated"
RSpec.shared_examples 'rate-limited frontend API requests' do
let(:requests_per_period) { 1 }
let(:csrf_token) { SecureRandom.base64(ActionController::RequestForgeryProtection::AUTHENTICITY_TOKEN_LENGTH) }
let(:csrf_session) { { _csrf_token: csrf_token } }
let(:personal_access_token) { nil }
let(:api_path) { '/projects' }
# These don't actually exist, so a 404 is the expected response.
let(:files_api_path) { '/projects/1/repository/files/ref/path' }
let(:packages_api_path) { '/projects/1/packages/foo' }
let(:deprecated_api_path) { '/groups/1?with_projects=true' }
def get_api(path: api_path, csrf: false)
headers = csrf ? { 'X-CSRF-Token' => csrf_token } : nil
get api(path, personal_access_token: personal_access_token), headers: headers
end
def expect_not_found(&block)
yield
expect(response).to have_gitlab_http_status(:not_found)
end
before do
stub_application_setting(
"#{throttle_setting_prefix}_enabled" => true,
"#{throttle_setting_prefix}_requests_per_period" => requests_per_period,
"#{throttle_setting_prefix}_api_enabled" => true,
"#{throttle_setting_prefix}_api_requests_per_period" => requests_per_period,
"#{throttle_setting_prefix}_web_enabled" => true,
"#{throttle_setting_prefix}_web_requests_per_period" => requests_per_period,
"#{throttle_setting_prefix}_files_api_enabled" => true,
"#{throttle_setting_prefix}_packages_api_enabled" => true,
"#{throttle_setting_prefix}_deprecated_api_enabled" => true
)
stub_session(csrf_session)
end
context 'with a CSRF token' do
it 'uses the rate limit for web requests' do
requests_per_period.times { get_api csrf: true }
expect_rejection("#{throttle_setting_prefix}_web") { get_api csrf: true }
expect_rejection("#{throttle_setting_prefix}_web") { get_api csrf: true, path: files_api_path }
expect_rejection("#{throttle_setting_prefix}_web") { get_api csrf: true, path: packages_api_path }
expect_rejection("#{throttle_setting_prefix}_web") { get_api csrf: true, path: deprecated_api_path }
# API rate limit is not triggered yet
expect_ok { get_api }
expect_not_found { get_api path: files_api_path }
expect_not_found { get_api path: packages_api_path }
expect_not_found { get_api path: deprecated_api_path }
end
context 'without a CSRF session' do
let(:csrf_session) { nil }
it 'always uses the rate limit for API requests' do
requests_per_period.times { get_api csrf: true }
expect_rejection("#{throttle_setting_prefix}_api") { get_api csrf: true }
expect_rejection("#{throttle_setting_prefix}_api") { get_api }
end
end
end
context 'without a CSRF token' do
it 'uses the rate limit for API requests' do
requests_per_period.times { get_api }
expect_rejection("#{throttle_setting_prefix}_api") { get_api }
# Web and custom API rate limits are not triggered yet
expect_ok { get_api csrf: true }
expect_not_found { get_api path: files_api_path }
expect_not_found { get_api path: packages_api_path }
expect_not_found { get_api path: deprecated_api_path }
end
end
end