diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-06 15:14:39 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-06 15:14:39 +0000 |
commit | 55242833f832095a6fcff00b1ccacbc5900ee52a (patch) | |
tree | 6e17b16638e60099533473b540fe8f635d2f25da | |
parent | 7c31b0312ba0eae4e4ebe54125b13aa2ae5f5db4 (diff) | |
download | gitlab-ce-55242833f832095a6fcff00b1ccacbc5900ee52a.tar.gz |
Add latest changes from gitlab-org/gitlab@master
48 files changed, 1198 insertions, 817 deletions
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js index 57de21c933e..bec7fe7e25f 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js @@ -1,157 +1,6 @@ -import { debounce } from 'lodash'; -import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; -import createFlash from '~/flash'; -import { sanitize } from '~/lib/dompurify'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import syntaxHighlight from '~/syntax_highlight'; -import { - EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, - EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, - EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, - EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, -} from '../constants'; -import { SourceEditorExtension } from './source_editor_extension_base'; - -const getPreview = (text, previewMarkdownPath) => { - return axios - .post(previewMarkdownPath, { - text, - }) - .then(({ data }) => { - return data.body; - }); -}; - -const setupDomElement = ({ injectToEl = null } = {}) => { - const previewEl = document.createElement('div'); - previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); - previewEl.style.display = 'none'; - if (injectToEl) { - injectToEl.appendChild(previewEl); - } - return previewEl; -}; - -export class EditorMarkdownExtension extends SourceEditorExtension { - constructor({ instance, previewMarkdownPath, ...args } = {}) { - super({ instance, ...args }); - Object.assign(instance, { - previewMarkdownPath, - preview: { - el: undefined, - action: undefined, - shown: false, - modelChangeListener: undefined, - }, - }); - this.setupPreviewAction.call(instance); - - instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { - if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { - instance.setupPreviewAction(); - } else { - instance.cleanup(); - } - }); - - instance.onDidChangeModel(() => { - const model = instance.getModel(); - if (model) { - const { language } = model.getLanguageIdentifier(); - instance.cleanup(); - if (language === 'markdown') { - instance.setupPreviewAction(); - } - } - }); - } - - static togglePreviewLayout() { - const { width, height } = this.getLayoutInfo(); - const newWidth = this.preview.shown - ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH - : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; - this.layout({ width: newWidth, height }); - } - - static togglePreviewPanel() { - const parentEl = this.getDomNode().parentElement; - const { el: previewEl } = this.preview; - parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); - - if (previewEl.style.display === 'none') { - // Show the preview panel - this.fetchPreview(); - } else { - // Hide the preview panel - previewEl.style.display = 'none'; - } - } - - cleanup() { - if (this.preview.modelChangeListener) { - this.preview.modelChangeListener.dispose(); - } - this.preview.action.dispose(); - if (this.preview.shown) { - EditorMarkdownExtension.togglePreviewPanel.call(this); - EditorMarkdownExtension.togglePreviewLayout.call(this); - } - this.preview.shown = false; - } - - fetchPreview() { - const { el: previewEl } = this.preview; - getPreview(this.getValue(), this.previewMarkdownPath) - .then((data) => { - previewEl.innerHTML = sanitize(data); - syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); - previewEl.style.display = 'block'; - }) - .catch(() => createFlash(BLOB_PREVIEW_ERROR)); - } - - setupPreviewAction() { - if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; - - this.preview.action = this.addAction({ - id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - label: __('Preview Markdown'), - keybindings: [ - // eslint-disable-next-line no-bitwise,no-undef - monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), - ], - contextMenuGroupId: 'navigation', - contextMenuOrder: 1.5, - - // Method that will be executed when the action is triggered. - // @param ed The editor instance is passed in as a convenience - run(instance) { - instance.togglePreview(); - }, - }); - } - - togglePreview() { - if (!this.preview?.el) { - this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement }); - } - EditorMarkdownExtension.togglePreviewLayout.call(this); - EditorMarkdownExtension.togglePreviewPanel.call(this); - - if (!this.preview?.shown) { - this.preview.modelChangeListener = this.onDidChangeModelContent( - debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY), - ); - } else { - this.preview.modelChangeListener.dispose(); - } - - this.preview.shown = !this.preview?.shown; - } +import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; +export class EditorMarkdownExtension extends EditorMarkdownPreviewExtension { getSelectedText(selection = this.getSelection()) { const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; const valArray = this.getValue().split('\n'); diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js new file mode 100644 index 00000000000..526de7f8932 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -0,0 +1,154 @@ +import { debounce } from 'lodash'; +import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; +import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import syntaxHighlight from '~/syntax_highlight'; +import { + EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, +} from '../constants'; +import { SourceEditorExtension } from './source_editor_extension_base'; + +const getPreview = (text, previewMarkdownPath) => { + return axios + .post(previewMarkdownPath, { + text, + }) + .then(({ data }) => { + return data.body; + }); +}; + +const setupDomElement = ({ injectToEl = null } = {}) => { + const previewEl = document.createElement('div'); + previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); + previewEl.style.display = 'none'; + if (injectToEl) { + injectToEl.appendChild(previewEl); + } + return previewEl; +}; + +export class EditorMarkdownPreviewExtension extends SourceEditorExtension { + constructor({ instance, previewMarkdownPath, ...args } = {}) { + super({ instance, ...args }); + Object.assign(instance, { + previewMarkdownPath, + preview: { + el: undefined, + action: undefined, + shown: false, + modelChangeListener: undefined, + }, + }); + this.setupPreviewAction.call(instance); + + instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { + if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { + instance.setupPreviewAction(); + } else { + instance.cleanup(); + } + }); + + instance.onDidChangeModel(() => { + const model = instance.getModel(); + if (model) { + const { language } = model.getLanguageIdentifier(); + instance.cleanup(); + if (language === 'markdown') { + instance.setupPreviewAction(); + } + } + }); + } + + static togglePreviewLayout() { + const { width, height } = this.getLayoutInfo(); + const newWidth = this.preview.shown + ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH + : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + this.layout({ width: newWidth, height }); + } + + static togglePreviewPanel() { + const parentEl = this.getDomNode().parentElement; + const { el: previewEl } = this.preview; + parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); + + if (previewEl.style.display === 'none') { + // Show the preview panel + this.fetchPreview(); + } else { + // Hide the preview panel + previewEl.style.display = 'none'; + } + } + + cleanup() { + if (this.preview.modelChangeListener) { + this.preview.modelChangeListener.dispose(); + } + this.preview.action.dispose(); + if (this.preview.shown) { + EditorMarkdownPreviewExtension.togglePreviewPanel.call(this); + EditorMarkdownPreviewExtension.togglePreviewLayout.call(this); + } + this.preview.shown = false; + } + + fetchPreview() { + const { el: previewEl } = this.preview; + getPreview(this.getValue(), this.previewMarkdownPath) + .then((data) => { + previewEl.innerHTML = sanitize(data); + syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); + previewEl.style.display = 'block'; + }) + .catch(() => createFlash(BLOB_PREVIEW_ERROR)); + } + + setupPreviewAction() { + if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; + + this.preview.action = this.addAction({ + id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + label: __('Preview Markdown'), + keybindings: [ + // eslint-disable-next-line no-bitwise,no-undef + monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), + ], + contextMenuGroupId: 'navigation', + contextMenuOrder: 1.5, + + // Method that will be executed when the action is triggered. + // @param ed The editor instance is passed in as a convenience + run(instance) { + instance.togglePreview(); + }, + }); + } + + togglePreview() { + if (!this.preview?.el) { + this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement }); + } + EditorMarkdownPreviewExtension.togglePreviewLayout.call(this); + EditorMarkdownPreviewExtension.togglePreviewPanel.call(this); + + if (!this.preview?.shown) { + this.preview.modelChangeListener = this.onDidChangeModelContent( + debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY), + ); + } else { + this.preview.modelChangeListener.dispose(); + } + + this.preview.shown = !this.preview?.shown; + } +} 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 d15619a78b0..ba831a33b73 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 @@ -165,7 +165,7 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="canUpdate" status="warning" /> <div class="media-body"> - <div class="gl-ml-3 float-left"> + <div class="float-left"> <span 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/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 19ac198964a..2edccce7f4e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -158,4 +158,7 @@ export const EXTENSION_ICON_CLASS = { severityUnknown: 'gl-text-gray-400', }; +export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500'; +export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700'; + export { STATE_MACHINE }; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0b0a416b7ef..2227047a909 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -146,6 +146,7 @@ export default { ref="textOutput" :style="levelIndentation" class="file-row-name" + :title="file.name" data-qa-selector="file_name_content" :data-qa-file-name="file.name" data-testid="file-row-name-container" diff --git a/app/controllers/concerns/one_trust_csp.rb b/app/controllers/concerns/one_trust_csp.rb index fbd44f52590..cd35eeb587c 100644 --- a/app/controllers/concerns/one_trust_csp.rb +++ b/app/controllers/concerns/one_trust_csp.rb @@ -8,11 +8,11 @@ module OneTrustCSP next unless helpers.one_trust_enabled? || policy.directives.present? default_script_src = policy.directives['script-src'] || policy.directives['default-src'] - script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org https://*.onetrust.com'] + script_src_values = Array.wrap(default_script_src) | ["'unsafe-eval'", 'https://cdn.cookielaw.org', 'https://*.onetrust.com'] policy.script_src(*script_src_values) default_connect_src = policy.directives['connect-src'] || policy.directives['default-src'] - connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org'] + connect_src_values = Array.wrap(default_connect_src) | ['https://cdn.cookielaw.org', 'https://*.onetrust.com'] policy.connect_src(*connect_src_values) end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index d4b1306cc5e..eb89fe58cc0 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -79,8 +79,6 @@ class InvitesController < ApplicationController if params[:experiment_name] == 'invite_email_preview_text' experiment(:invite_email_preview_text, actor: member).track(:join_clicked) - elsif params[:experiment_name] == 'invite_email_from' - experiment(:invite_email_from, actor: member).track(:join_clicked) end Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s) diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index f8f2c1f0836..660ebcc30d3 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -60,3 +60,5 @@ class Projects::TreeController < Projects::ApplicationController } end end + +Projects::TreeController.prepend_mod diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 450c12a233b..6dc5cf57a9e 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -212,7 +212,6 @@ class RegistrationsController < Devise::RegistrationsController experiment_name = session.delete(:invite_email_experiment_name) experiment(:invite_email_preview_text, actor: member).track(:accepted) if experiment_name == 'invite_email_preview_text' - experiment(:invite_email_from, actor: member).track(:accepted) if experiment_name == 'invite_email_from' Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s) end diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb index ed96f3cef4f..da42740f993 100644 --- a/app/helpers/notify_helper.rb +++ b/app/helpers/notify_helper.rb @@ -24,14 +24,7 @@ module NotifyHelper def invited_join_url(token, member) additional_params = { invite_type: Emails::Members::INITIAL_INVITE } - # order important below to our scheduled testing of these - # `from` experiment will be after the `text` on, but we may not cleanup - # from the `text` one by the time we run the `from` experiment, - # therefore we want to support `text` being fully enabled - # but if `from` is also enabled, then we only care about `from` - if experiment(:invite_email_from, actor: member).enabled? - additional_params[:experiment_name] = 'invite_email_from' - elsif experiment(:invite_email_preview_text, actor: member).enabled? + if experiment(:invite_email_preview_text, actor: member).enabled? additional_params[:experiment_name] = 'invite_email_preview_text' end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 8a9ed557cc6..ef2220751bf 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -61,7 +61,7 @@ module Emails Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s) - mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers.merge(additional_invite_settings)) do |format| + mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format| format.html { render layout: 'unknown_user_mailer' } format.text { render layout: 'unknown_user_mailer' } end @@ -151,17 +151,7 @@ module Emails def invite_email_subject if member.created_by - experiment(:invite_email_from, actor: member) do |experiment_instance| - experiment_instance.use do - subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name }) - end - - experiment_instance.candidate do - subject(s_("MemberInviteEmail|I've invited you to join me in GitLab")) - end - - experiment_instance.run - end + subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name }) else subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular }) end @@ -178,21 +168,6 @@ module Emails end end - def additional_invite_settings - return {} unless member.created_by - - experiment(:invite_email_from, actor: member) do |experiment_instance| - experiment_instance.use { {} } - experiment_instance.candidate do - { - from: "#{member.created_by.name} <#{member.created_by.email}>" - } - end - - experiment_instance.run - end - end - def member_exists? Gitlab::AppLogger.info("Tried to send an email invitation for a deleted group. Member id: #{@member_id}") if member.blank? member.present? diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index a18b760eeb4..6a924c1b576 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -1287,6 +1287,12 @@ module Ci end end + def create_deployment_in_separate_transaction? + strong_memoize(:create_deployment_in_separate_transaction) do + ::Feature.enabled?(:create_deployment_in_separate_transaction, project, default_enabled: :yaml) + end + end + private def add_message(severity, content) diff --git a/app/models/user.rb b/app/models/user.rb index 0435e872c22..5ecf5edb12f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -993,11 +993,7 @@ class User < ApplicationRecord # Returns the groups a user is a member of, either directly or through a parent group def membership_groups - if Feature.enabled?(:linear_user_membership_groups, self, default_enabled: :yaml) - groups.self_and_descendants - else - Gitlab::ObjectHierarchy.new(groups).base_and_descendants - end + groups.self_and_descendants end # Returns a relation of groups the user has access to, including their parent diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 0548566c271..c1f35afba40 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -28,7 +28,10 @@ module Ci Gitlab::Ci::Pipeline::Chain::Validate::External, Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::StopDryRun, + Gitlab::Ci::Pipeline::Chain::EnsureEnvironments, + Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups, Gitlab::Ci::Pipeline::Chain::Create, + Gitlab::Ci::Pipeline::Chain::CreateDeployments, Gitlab::Ci::Pipeline::Chain::CreateCrossDatabaseAssociations, Gitlab::Ci::Pipeline::Chain::Limit::Activity, Gitlab::Ci::Pipeline::Chain::Limit::JobActivity, diff --git a/config/feature_flags/development/linear_user_membership_groups.yml b/config/feature_flags/development/create_deployment_in_separate_transaction.yml index 19bca849090..7d07a932966 100644 --- a/config/feature_flags/development/linear_user_membership_groups.yml +++ b/config/feature_flags/development/create_deployment_in_separate_transaction.yml @@ -1,8 +1,8 @@ --- -name: linear_user_membership_groups -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68842 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339432 -milestone: '14.3' +name: create_deployment_in_separate_transaction +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75604 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/346879 +milestone: '14.6' type: development -group: group::access +group: group::release default_enabled: false diff --git a/config/feature_flags/experiment/invite_email_from.yml b/config/feature_flags/experiment/invite_email_from.yml deleted file mode 100644 index 59baf249341..00000000000 --- a/config/feature_flags/experiment/invite_email_from.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: invite_email_from -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68376 -rollout_issue_url: https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/429 -milestone: '14.3' -type: experiment -group: group::expansion -default_enabled: false diff --git a/config/metrics/counts_28d/20210216181158_epics.yml b/config/metrics/counts_28d/20210216181158_epics.yml index 59b6cbad145..57dd292b17a 100644 --- a/config/metrics/counts_28d/20210216181158_epics.yml +++ b/config/metrics/counts_28d/20210216181158_epics.yml @@ -15,7 +15,5 @@ distribution: tier: - premium - ultimate -performance_indicator_type: -- gmau -- paid_gmau +performance_indicator_type: [] milestone: "<13.9" diff --git a/doc/architecture/blueprints/ci_scale/index.md b/doc/architecture/blueprints/ci_scale/index.md index 3e9fbc534d5..af1cac42241 100644 --- a/doc/architecture/blueprints/ci_scale/index.md +++ b/doc/architecture/blueprints/ci_scale/index.md @@ -5,7 +5,7 @@ comments: false description: 'Improve scalability of GitLab CI/CD' --- -# Next CI/CD scale target: 20M builds per day by 2024 +# CI/CD Scaling ## Summary @@ -20,13 +20,8 @@ store all the builds in PostgreSQL in `ci_builds` table, and because we are creating more than [2 million builds each day on GitLab.com](https://docs.google.com/spreadsheets/d/17ZdTWQMnTHWbyERlvj1GA7qhw_uIfCoI5Zfrrsh95zU), we are reaching database limits that are slowing our development velocity down. -On February 1st, 2021, a billionth CI/CD job was created and the number of -builds is growing exponentially. We will run out of the available primary keys -for builds before December 2021 unless we improve the database model used to -store CI/CD data. - -We expect to see 20M builds created daily on GitLab.com in the first half of -2024. +On February 1st, 2021, GitLab.com surpased 1 billion CI/CD builds created and the number of +builds continues to grow exponentially. ![CI builds cumulative with forecast](ci_builds_cumulative_forecast.png) @@ -60,8 +55,8 @@ that have the same problem. Primary keys problem will be tackled by our Database Team. -Status: As of October 2021 the primary keys in CI tables have been migrated to -big integers. +**Status**: As of October 2021 the primary keys in CI tables have been migrated +to big integers. ### The table is too large @@ -84,6 +79,14 @@ seem fine in the development environment may not work on GitLab.com. The difference in the dataset size between the environments makes it difficult to predict the performance of even the most simple queries. +Team members and the wider community members are struggling to contribute the +Verify area, because we restricted the possibility of extending `ci_builds` +even further. Our static analysis tools prevent adding more columns to this +table. Adding new queries is unpredictable because of the size of the dataset +and the amount of queries executed using the table. This significantly hinders +the development velocity and contributes to incidents on the production +environment. + We also expect a significant, exponential growth in the upcoming years. One of the forecasts done using [Facebook's @@ -94,6 +97,10 @@ sustain in upcoming years. ![CI builds daily forecast](ci_builds_daily_forecast.png) +**Status**: As of October 2021 we reduced the growth rate of `ci_builds` table +by writing build options and variables to `ci_builds_metadata` table. We plan +to ship futher improvements that will be described in a separate blueprint. + ### Queuing mechanisms are using the large table Because of how large the table is, mechanisms that we use to build queues of @@ -114,8 +121,8 @@ table that will accelerate SQL queries used to build queues](https://gitlab.com/gitlab-org/gitlab/-/issues/322766) and we want to explore them. -Status: the new architecture [has been implemented on GitLab.com](https://gitlab.com/groups/gitlab-org/-/epics/5909#note_680407908). - +**Status**: As of October 2021 the new architecture [has been implemented on +GitLab.com](https://gitlab.com/groups/gitlab-org/-/epics/5909#note_680407908). The following epic tracks making it generally available: [Make the new pending builds architecture generally available]( https://gitlab.com/groups/gitlab-org/-/epics/6954). @@ -136,17 +143,8 @@ columns, tables, partitions or database shards. Effort to improve background migrations will be owned by our Database Team. -Status: In progress. - -### Development velocity is negatively affected - -Team members and the wider community members are struggling to contribute the -Verify area, because we restricted the possibility of extending `ci_builds` -even further. Our static analysis tools prevent adding more columns to this -table. Adding new queries is unpredictable because of the size of the dataset -and the amount of queries executed using the table. This significantly hinders -the development velocity and contributes to incidents on the production -environment. +**Status**: In progress. We plan to ship further improvements that will be +described in a separate architectural blueprint. ## Proposal @@ -157,32 +155,34 @@ First, we want to focus on things that are urgently needed right now. We need to fix primary keys overflow risk and unblock other teams that are working on database partitioning and sharding. -We want to improve situation around bottlenecks that are known already, like -queuing mechanisms using the large table and things that are holding other -teams back. +We want to improve known bottlenecks, like +builds queuing mechanisms that is using the large table, and other things that +are holding other teams back. Extending CI/CD metrics is important to get a better sense of how the system performs and to what growth should we expect. This will make it easier for us to identify bottlenecks and perform more advanced capacity planning. -As we work on first iterations we expect our Database Sharding team and -Database Scalability Working Group to make progress on patterns we will be able -to use to partition the large CI/CD dataset. We consider the strong time-decay -effect, related to the diminishing importance of pipelines with time, as an -opportunity we might want to seize. +Next step is to better understand how we can leverage strong time-decay +characteristic of CI/CD data. This might help us to partition CI/CD dataset to +reduce the size of CI/CD database tables. ## Iterations Work required to achieve our next CI/CD scaling target is tracked in the -[GitLab CI/CD 20M builds per day scaling -target](https://gitlab.com/groups/gitlab-org/-/epics/5745) epic. +[CI/CD Scaling](https://gitlab.com/groups/gitlab-org/-/epics/5745) epic. + +1. ✓ Migrate primary keys to big integers on GitLab.com. +1. ✓ Implement the new architecture of builds queuing on GitLab.com. +1. Make the new builds queuing architecture generally available. +1. Partition CI/CD data using time-decay pattern. ## Status |-------------|--------------| | Created at | 21.01.2021 | | Approved at | 26.04.2021 | -| Updated at | 28.10.2021 | +| Updated at | 06.12.2021 | Status: In progress. @@ -215,6 +215,7 @@ Domain experts: | Area | Who |------------------------------|------------------------| | Domain Expert / Verify | Fabio Pitino | +| Domain Expert / Verify | Marius Bobin | | Domain Expert / Database | Jose Finotto | | Domain Expert / PostgreSQL | Nikolay Samokhvalov | diff --git a/doc/development/deprecation_guidelines/index.md b/doc/development/deprecation_guidelines/index.md index f8ee29e6904..27c29a1ed7c 100644 --- a/doc/development/deprecation_guidelines/index.md +++ b/doc/development/deprecation_guidelines/index.md @@ -23,7 +23,9 @@ deprecated. A feature can be deprecated at any time, provided there is a viable alternative. -Deprecations should be announced via [release posts](https://about.gitlab.com/handbook/marketing/blog/release-posts/#deprecations). +Deprecations should be announced on the [Deprecated feature removal schedule](../../update/deprecations.md). + +For steps to create a deprecation entry, see [Deprecations](https://about.gitlab.com/handbook/marketing/blog/release-posts/#deprecations). ## When can a feature be removed/changed? diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md index bc8c8fca5b5..f7e9d8f64b0 100644 --- a/doc/user/project/releases/index.md +++ b/doc/user/project/releases/index.md @@ -1,5 +1,4 @@ --- -type: reference, howto stage: Release group: Release info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments @@ -9,26 +8,33 @@ info: To determine the technical writer assigned to the Stage/Group associated w > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/41766) in GitLab 11.7. -To introduce a checkpoint in your source code history, you can assign a -[Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) at the moment of release. -However, in most cases, your users need more than just the raw source code. -They need compiled objects or other assets output by your CI/CD system. +In GitLab, a release enables you to create a snapshot of your project for your users, including +installation packages and release notes. You can create a GitLab release on any branch. Creating a +release also creates a [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) to mark the +release point in the source code. -A GitLab Release can be: +WARNING: +Deleting a Git tag associated with a release also deletes the release. + +A release can include: - A snapshot of the source code of your repository. - [Generic packages](../../packages/generic_packages/index.md) created from job artifacts. - Other metadata associated with a released version of your code. +- Release notes. -You can create a GitLab release on any branch. When you create a release: +When you [create a release](#create-a-release): - GitLab automatically archives source code and associates it with the release. - GitLab automatically creates a JSON file that lists everything in the release, so you can compare and audit releases. This file is called [release evidence](#release-evidence). -- You can add release notes and a message for the tag associated with the release. -After you create a release, you can [associate milestones with it](#associate-milestones-with-a-release), -and attach [release assets](#release-assets), like runbooks or packages. +When you create a release, or after, you can: + +- Add release notes. +- Add a message for the Git tag associated with the release. +- [Associate milestones with it](#associate-milestones-with-a-release). +- Attach [release assets](#release-assets), like runbooks or packages. ## View releases @@ -57,38 +63,73 @@ switch between ascending or descending order, select **Sort order**. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/32812) in GitLab 12.9. Releases can be created directly in the GitLab UI. -You can create a release in the user interface, or by using the -[Releases API](../../../api/releases/index.md#create-a-release). -We recommend using the API to create releases as one of the last steps in your -CI/CD pipeline. +You can create a release: -Only users with at least the Developer role can create releases. -Read more about [Release permissions](#release-permissions). +- [Using a job in your CI/CD pipeline](#create-a-release-by-using-a-cicd-job). +- [In the Releases page](#create-a-release-in-the-releases-page). +- [In the Tags page](#create-a-release-in-the-tags-page). +- Using the [Releases API](../../../api/releases/index.md#create-a-release). + +We recommend creating a release as one of the last steps in your CI/CD pipeline. -To create a new release through the GitLab UI: +Prerequisites: +- You must have at least the Developer role for a project. For more information, read +[Release permissions](#release-permissions). + +### Create a release in the Releases page + +To create a release in the Releases page: + +1. On the top bar, select **Menu > Projects** and find your project. 1. On the left sidebar, select **Deployments > Releases** and select **New release**. -1. Open the [**Tag name**](#tag-name) dropdown. Select an existing tag or type - in a new tag name. Selecting an existing tag that is already associated with - a release will result in a validation error. -1. If creating a new tag, open the **Create from** dropdown. Select a - branch, tag, or commit SHA to use when creating the new tag. -1. Optionally, fill out any additional information about the release, such as its - [title](#title), [milestones](#associate-milestones-with-a-release), - [release notes](#release-notes-description), or [assets links](#links). -1. Click **Create release**. - -## Create a release by using a CI/CD job +1. From the [**Tag name**](#tag-name) dropdown, either: + - Select an existing Git tag. Selecting an existing tag that is already associated with a release + results in a validation error. + - Enter a new Git tag name. + 1. From the **Create from** dropdown, select a branch or commit SHA to use when creating the + new tag. +1. Optional. Enter additional information about the release, including: + - [Title](#title). + - [Milestones](#associate-milestones-with-a-release). + - [Release notes](#release-notes-description). + - [Asset links](#links). +1. Select **Create release**. + +### Create a release in the Tags page + +To create a release in the Tags page, add release notes to either an existing or a new Git tag. + +To add release notes to a new Git tag: + +1. On the top bar, select **Menu > Projects** and find your project. +1. On the left sidebar, select **Repository > Tags**. +1. Select **New tag**. +1. Optional. Enter a tag message in the **Message** text box. +1. In the **Release notes** text box, enter the release's description. + You can use Markdown and drag and drop files to this text box. +1. Select **Create tag**. + +To edit release notes of an existing Git tag: + +1. On the top bar, select **Menu > Projects** and find your project. +1. On the left sidebar, select **Repository > Tags**. +1. Select **Edit release notes** (**{pencil}**). +1. In the **Release notes** text box, enter the release's description. + You can use Markdown and drag and drop files to this text box. +1. Select **Save changes**. + +### Create a release by using a CI/CD job > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/19298) in GitLab 12.7. You can create a release directly as part of the GitLab CI/CD pipeline -by using [the `release` keyword](../../../ci/yaml/index.md#release) in the job definition. +by using the [`release` keyword](../../../ci/yaml/index.md#release) in the job definition. -The release is created only if the job processes without error. If the Rails API returns an error -during release creation, the release job fails. +The release is created only if the job processes without error. If the API returns an error during +release creation, the release job fails. -### CI/CD example of the `release` keyword +#### CI/CD example of the `release` keyword To create a release when you push a Git tag, or when you add a Git tag in the UI by going to **Repository > Tags**: @@ -207,7 +248,7 @@ which requires the text representation of the certificate. ### `release-cli` command line -The entries under the `release` node are transformed into a `bash` command line and sent +The entries under the `release` node are transformed into Bash commands and sent to the Docker container, which contains the [release-cli](https://gitlab.com/gitlab-org/release-cli). You can also call the `release-cli` directly from a `script` entry. @@ -255,7 +296,8 @@ release tag. When the `released_at` date and time has passed, the badge is autom ## Edit a release -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/26016) in GitLab 12.6. Asset link editing was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9427) in GitLab 12.10. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/26016) in GitLab 12.6. +> - Asset link editing [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9427) in GitLab 12.10. Only users with at least the Developer role can edit releases. Read more about [Release permissions](#release-permissions). @@ -271,29 +313,6 @@ You can edit the release title, notes, associated milestones, and asset links. To change the release date use the [Releases API](../../../api/releases/index.md#update-a-release). -## Add release notes to Git tags - -If you have an existing Git tag, you can add release notes to it. - -You can do this in the user interface, or by using the [Releases API](../../../api/releases/index.md). -We recommend using the API to add release notes as one of the last steps in your CI/CD release pipeline. - -In the interface, to add release notes to a new Git tag: - -1. Navigate to your project's **Repository > Tags**. -1. Click **New tag**. -1. In the **Release notes** field, enter the release's description. - You can use Markdown and drag and drop files to this field. -1. Click **Create tag**. - -In the interface, to add release notes to an existing Git tag: - -1. Navigate to your project's **Repository > Tags**. -1. Click **Edit release notes** (the pencil icon). -1. In the **Release notes** field, enter the release's description. - You can use Markdown in this field, and drag and drop files to it. -1. Click **Save changes**. - ## Associate milestones with a release > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/29020) in GitLab 12.5. diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index e966c3dfd0a..17b65fa21c3 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -292,3 +292,5 @@ module API end end end + +API::Repositories.prepend_mod diff --git a/lib/gitlab/ci/pipeline/chain/build.rb b/lib/gitlab/ci/pipeline/chain/build.rb index 6feb693221b..bbdc6b65b96 100644 --- a/lib/gitlab/ci/pipeline/chain/build.rb +++ b/lib/gitlab/ci/pipeline/chain/build.rb @@ -21,6 +21,10 @@ module Gitlab merge_request: @command.merge_request, external_pull_request: @command.external_pull_request, locked: @command.project.default_pipeline_lock) + + # Initialize the feature flag at the beginning of the pipeline creation process + # so that the flag references in the latter chains return the same value. + @pipeline.create_deployment_in_separate_transaction? end def break? diff --git a/lib/gitlab/ci/pipeline/chain/create_deployments.rb b/lib/gitlab/ci/pipeline/chain/create_deployments.rb new file mode 100644 index 00000000000..b92aa89d62d --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/create_deployments.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + class CreateDeployments < Chain::Base + DeploymentCreationError = Class.new(StandardError) + + def perform! + return unless pipeline.create_deployment_in_separate_transaction? + + create_deployments! + end + + def break? + false + end + + private + + def create_deployments! + pipeline.stages.map(&:statuses).flatten.map(&method(:create_deployment)) + end + + def create_deployment(build) + return unless build.instance_of?(::Ci::Build) && build.persisted_environment.present? + + 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 diff --git a/lib/gitlab/ci/pipeline/chain/ensure_environments.rb b/lib/gitlab/ci/pipeline/chain/ensure_environments.rb new file mode 100644 index 00000000000..424e1d87fb4 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/ensure_environments.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + class EnsureEnvironments < Chain::Base + def perform! + return unless pipeline.create_deployment_in_separate_transaction? + + pipeline.stages.map(&:statuses).flatten.each(&method(:ensure_environment)) + end + + def break? + false + end + + private + + def ensure_environment(build) + return unless build.instance_of?(::Ci::Build) && build.has_environment? + + environment = ::Gitlab::Ci::Pipeline::Seed::Environment.new(build).to_resource + + if environment.persisted? + build.persisted_environment = environment + build.assign_attributes(metadata_attributes: { expanded_environment_name: environment.name }) + else + build.assign_attributes(status: :failed, failure_reason: :environment_creation_failure) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/ensure_resource_groups.rb b/lib/gitlab/ci/pipeline/chain/ensure_resource_groups.rb new file mode 100644 index 00000000000..f4e5e6e467a --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/ensure_resource_groups.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + class EnsureResourceGroups < Chain::Base + def perform! + return unless pipeline.create_deployment_in_separate_transaction? + + pipeline.stages.map(&:statuses).flatten.each(&method(:ensure_resource_group)) + end + + def break? + false + end + + private + + def ensure_resource_group(processable) + return unless processable.is_a?(::Ci::Processable) + + key = processable.options.delete(:resource_group_key) + + resource_group = ::Gitlab::Ci::Pipeline::Seed::Processable::ResourceGroup + .new(processable, key).to_resource + + processable.resource_group = resource_group + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index bb8831095e4..c92f6b6036e 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -78,7 +78,7 @@ module Gitlab def to_resource strong_memoize(:resource) do processable = initialize_processable - assign_resource_group(processable) + assign_resource_group(processable) unless @pipeline.create_deployment_in_separate_transaction? processable end end @@ -88,7 +88,9 @@ module Gitlab ::Ci::Bridge.new(attributes) else ::Ci::Build.new(attributes).tap do |build| - build.assign_attributes(self.class.deployment_attributes_for(build)) + unless @pipeline.create_deployment_in_separate_transaction? + build.assign_attributes(self.class.deployment_attributes_for(build)) + end end end end diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb index 6215ba40ebe..f14279dca2d 100644 --- a/lib/gitlab/ci/yaml_processor/result.rb +++ b/lib/gitlab/ci/yaml_processor/result.rb @@ -92,6 +92,7 @@ module Gitlab script: job[:script], after_script: job[:after_script], environment: job[:environment], + resource_group_key: job[:resource_group], retry: job[:retry], parallel: job[:parallel], instance: job[:instance], diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 0301525ebbd..7ce0e980f30 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -729,7 +729,7 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord # rubocop: disable UsageData/LargeTable start = ::Event.where(time_period).select(:id).order(created_at: :asc).first&.id - finish = ::Event.where(time_period).select(:id).order(created_at: :asc).first&.id + finish = ::Event.where(time_period).select(:id).order(created_at: :desc).first&.id estimate_batch_distinct_count(::Event.where(time_period), :author_id, start: start, finish: finish) # rubocop: enable UsageData/LargeTable # rubocop: enable CodeReuse/ActiveRecord diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 11eba8b3392..cb3d94c246d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1212,6 +1212,9 @@ msgstr "" msgid "+%{tags} more" msgstr "" +msgid ", and " +msgstr "" + msgid ", or " msgstr "" @@ -21706,9 +21709,6 @@ msgstr "" msgid "MemberInviteEmail|%{member_name} invited you to join GitLab" msgstr "" -msgid "MemberInviteEmail|I've invited you to join me in GitLab" -msgstr "" - msgid "MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}" msgstr "" @@ -33287,6 +33287,9 @@ msgstr "" msgid "Status: %{title}" msgstr "" +msgid "StatusCheck|%{failed} failed" +msgstr "" + msgid "StatusCheck|%{pending} pending" msgstr "" @@ -33317,6 +33320,9 @@ msgstr "" msgid "StatusCheck|External API is already in use by another status check." msgstr "" +msgid "StatusCheck|Failed to load status checks" +msgstr "" + msgid "StatusCheck|Failed to load status checks." msgstr "" @@ -33338,6 +33344,12 @@ msgstr "" msgid "StatusCheck|Status checks" msgstr "" +msgid "StatusCheck|Status checks all passed" +msgstr "" + +msgid "StatusCheck|Status checks are being fetched" +msgstr "" + msgid "StatusCheck|Status to check" msgstr "" @@ -33353,6 +33365,9 @@ msgstr "" msgid "StatusCheck|You are about to remove the %{name} status check." msgstr "" +msgid "StatusCheck|status checks" +msgstr "" + msgid "StatusPage|AWS %{docsLink}" msgstr "" diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index d4091461062..dc1fb0454df 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -120,29 +120,6 @@ RSpec.describe InvitesController do end end - context 'when it is part of the invite_email_from experiment' do - let(:extra_params) { { invite_type: 'initial_email', experiment_name: 'invite_email_from' } } - - it 'tracks the initial join click from email' do - experiment = double(track: true) - allow(controller).to receive(:experiment).with(:invite_email_from, actor: member).and_return(experiment) - - request - - expect(experiment).to have_received(:track).with(:join_clicked) - end - - context 'when member does not exist' do - let(:raw_invite_token) { '_bogus_token_' } - - it 'does not track the experiment' do - expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member) - - request - end - end - end - context 'when member does not exist' do let(:raw_invite_token) { '_bogus_token_' } @@ -170,9 +147,8 @@ RSpec.describe InvitesController do end context 'when it is not part of our invite email experiment' do - it 'does not track via experiment', :aggregate_failures do + it 'does not track via experiment' do expect(controller).not_to receive(:experiment).with(:invite_email_preview_text, actor: member) - expect(controller).not_to receive(:experiment).with(:invite_email_from, actor: member) request end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index baf500c2b57..9094d235366 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -227,40 +227,6 @@ RSpec.describe RegistrationsController do end end end - - context 'with the invite_email_preview_text experiment', :experiment do - let(:extra_session_params) { { invite_email_experiment_name: 'invite_email_from' } } - - context 'when member and invite_email_experiment_name exists from the session key value' do - it 'tracks the invite acceptance' do - expect(experiment(:invite_email_from)).to track(:accepted) - .with_context(actor: member) - .on_next_instance - - subject - end - end - - context 'when member does not exist from the session key value' do - let(:originating_member_id) { -1 } - - it 'does not track invite acceptance' do - expect(experiment(:invite_email_from)).not_to track(:accepted) - - subject - end - end - - context 'when invite_email_experiment_name does not exist from the session key value' do - let(:extra_session_params) { {} } - - it 'does not track invite acceptance' do - expect(experiment(:invite_email_from)).not_to track(:accepted) - - subject - end - end - end end context 'when invite email matches email used on registration' do diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb index f9ab780d2d6..d2bf35166ac 100644 --- a/spec/features/invites_spec.rb +++ b/spec/features/invites_spec.rb @@ -240,20 +240,6 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do end end - context 'with invite email acceptance for the invite_email_from experiment', :experiment do - let(:extra_params) do - { invite_type: Emails::Members::INITIAL_INVITE, experiment_name: 'invite_email_from' } - end - - it 'tracks the accepted invite' do - expect(experiment(:invite_email_from)).to track(:accepted) - .with_context(actor: group_invite) - .on_next_instance - - fill_in_sign_up_form(new_user) - end - end - it 'signs up and redirects to the group activity page with all the project/groups invitation automatically accepted' do fill_in_sign_up_form(new_user) fill_in_welcome_form diff --git a/spec/features/users/one_trust_csp_spec.rb b/spec/features/users/one_trust_csp_spec.rb new file mode 100644 index 00000000000..382a0b4be6c --- /dev/null +++ b/spec/features/users/one_trust_csp_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'OneTrust content security policy' do + let(:user) { create(:user) } + + before do + stub_config(extra: { one_trust_id: SecureRandom.uuid }) + end + + it 'has proper Content Security Policy headers' do + visit root_path + + expect(response_headers['Content-Security-Policy']).to include('https://cdn.cookielaw.org https://*.onetrust.com') + end +end diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 4a53f870f6d..4a50d801296 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,36 +1,20 @@ import MockAdapter from 'axios-mock-adapter'; -import { Range, Position, editor as monacoEditor } from 'monaco-editor'; -import waitForPromises from 'helpers/wait_for_promises'; -import { - EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, - EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, - EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, - EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, -} from '~/editor/constants'; +import { Range, Position } from 'monaco-editor'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import SourceEditor from '~/editor/source_editor'; -import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import syntaxHighlight from '~/syntax_highlight'; - -jest.mock('~/syntax_highlight'); -jest.mock('~/flash'); describe('Markdown Extension for Source Editor', () => { let editor; let instance; let editorEl; - let panelSpy; let mockAxios; const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown'; const firstLine = 'This is a'; const secondLine = 'multiline'; const thirdLine = 'string with some **markup**'; const text = `${firstLine}\n${secondLine}\n${thirdLine}`; - const plaintextPath = 'foo.txt'; const markdownPath = 'foo.md'; - const responseData = '<div>FooBar</div>'; const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); @@ -42,11 +26,6 @@ describe('Markdown Extension for Source Editor', () => { const selectionToString = () => instance.getSelection().toString(); const positionToString = () => instance.getPosition().toString(); - const togglePreview = async () => { - instance.togglePreview(); - await waitForPromises(); - }; - beforeEach(() => { mockAxios = new MockAdapter(axios); setFixtures('<div id="editor" data-editor-loading></div>'); @@ -58,7 +37,6 @@ describe('Markdown Extension for Source Editor', () => { blobContent: text, }); instance.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); - panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel'); }); afterEach(() => { @@ -67,345 +45,6 @@ describe('Markdown Extension for Source Editor', () => { mockAxios.restore(); }); - it('sets up the instance', () => { - expect(instance.preview).toEqual({ - el: undefined, - action: expect.any(Object), - shown: false, - modelChangeListener: undefined, - }); - expect(instance.previewMarkdownPath).toBe(previewMarkdownPath); - }); - - describe('model language changes listener', () => { - let cleanupSpy; - let actionSpy; - - beforeEach(async () => { - cleanupSpy = jest.spyOn(instance, 'cleanup'); - actionSpy = jest.spyOn(instance, 'setupPreviewAction'); - await togglePreview(); - }); - - it('cleans up when switching away from markdown', () => { - expect(instance.cleanup).not.toHaveBeenCalled(); - expect(instance.setupPreviewAction).not.toHaveBeenCalled(); - - instance.updateModelLanguage(plaintextPath); - - expect(cleanupSpy).toHaveBeenCalled(); - expect(actionSpy).not.toHaveBeenCalled(); - }); - - it.each` - oldLanguage | newLanguage | setupCalledTimes - ${'plaintext'} | ${'markdown'} | ${1} - ${'markdown'} | ${'markdown'} | ${0} - ${'markdown'} | ${'plaintext'} | ${0} - ${'markdown'} | ${undefined} | ${0} - ${undefined} | ${'markdown'} | ${1} - `( - 'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage', - ({ oldLanguage, newLanguage, setupCalledTimes } = {}) => { - expect(actionSpy).not.toHaveBeenCalled(); - instance.updateModelLanguage(oldLanguage); - instance.updateModelLanguage(newLanguage); - expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); - }, - ); - }); - - describe('model change listener', () => { - let cleanupSpy; - let actionSpy; - - beforeEach(() => { - cleanupSpy = jest.spyOn(instance, 'cleanup'); - actionSpy = jest.spyOn(instance, 'setupPreviewAction'); - instance.togglePreview(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('does not do anything if there is no model', () => { - instance.setModel(null); - - expect(cleanupSpy).not.toHaveBeenCalled(); - expect(actionSpy).not.toHaveBeenCalled(); - }); - - it('cleans up the preview when the model changes', () => { - instance.setModel(monacoEditor.createModel('foo')); - expect(cleanupSpy).toHaveBeenCalled(); - }); - - it.each` - language | setupCalledTimes - ${'markdown'} | ${1} - ${'plaintext'} | ${0} - ${undefined} | ${0} - `( - 'correctly handles actions when the new model is $language', - ({ language, setupCalledTimes } = {}) => { - instance.setModel(monacoEditor.createModel('foo', language)); - - expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); - }, - ); - }); - - describe('cleanup', () => { - beforeEach(async () => { - mockAxios.onPost().reply(200, { body: responseData }); - await togglePreview(); - }); - - it('disposes the modelChange listener and does not fetch preview on content changes', () => { - expect(instance.preview.modelChangeListener).toBeDefined(); - jest.spyOn(instance, 'fetchPreview'); - - instance.cleanup(); - instance.setValue('Foo Bar'); - jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY); - - expect(instance.fetchPreview).not.toHaveBeenCalled(); - }); - - it('removes the contextual menu action', () => { - expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); - - instance.cleanup(); - - expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null); - }); - - it('toggles the `shown` flag', () => { - expect(instance.preview.shown).toBe(true); - instance.cleanup(); - expect(instance.preview.shown).toBe(false); - }); - - it('toggles the panel only if the preview is visible', () => { - const { el: previewEl } = instance.preview; - const parentEl = previewEl.parentElement; - - expect(previewEl).toBeVisible(); - expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true); - - instance.cleanup(); - expect(previewEl).toBeHidden(); - expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( - false, - ); - - instance.cleanup(); - expect(previewEl).toBeHidden(); - expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( - false, - ); - }); - - it('toggles the layout only if the preview is visible', () => { - const { width } = instance.getLayoutInfo(); - - expect(instance.preview.shown).toBe(true); - - instance.cleanup(); - - const { width: newWidth } = instance.getLayoutInfo(); - expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); - - instance.cleanup(); - expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); - }); - }); - - describe('fetchPreview', () => { - const fetchPreview = async () => { - instance.fetchPreview(); - await waitForPromises(); - }; - - let previewMarkdownSpy; - - beforeEach(() => { - previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]); - mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req)); - }); - - it('correctly fetches preview based on previewMarkdownPath', async () => { - await fetchPreview(); - - expect(previewMarkdownSpy).toHaveBeenCalledWith( - expect.objectContaining({ data: JSON.stringify({ text }) }), - ); - }); - - it('puts the fetched content into the preview DOM element', async () => { - instance.preview.el = editorEl.parentElement; - await fetchPreview(); - expect(instance.preview.el.innerHTML).toEqual(responseData); - }); - - it('applies syntax highlighting to the preview content', async () => { - instance.preview.el = editorEl.parentElement; - await fetchPreview(); - expect(syntaxHighlight).toHaveBeenCalled(); - }); - - it('catches the errors when fetching the preview', async () => { - mockAxios.onPost().reply(500); - - await fetchPreview(); - expect(createFlash).toHaveBeenCalled(); - }); - }); - - describe('setupPreviewAction', () => { - it('adds the contextual menu action', () => { - expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); - }); - - it('does not set up action if one already exists', () => { - jest.spyOn(instance, 'addAction').mockImplementation(); - - instance.setupPreviewAction(); - expect(instance.addAction).not.toHaveBeenCalled(); - }); - - it('toggles preview when the action is triggered', () => { - jest.spyOn(instance, 'togglePreview').mockImplementation(); - - expect(instance.togglePreview).not.toHaveBeenCalled(); - - const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID); - action.run(); - - expect(instance.togglePreview).toHaveBeenCalled(); - }); - }); - - describe('togglePreview', () => { - beforeEach(() => { - mockAxios.onPost().reply(200, { body: responseData }); - }); - - it('toggles preview flag on instance', () => { - expect(instance.preview.shown).toBe(false); - - instance.togglePreview(); - expect(instance.preview.shown).toBe(true); - - instance.togglePreview(); - expect(instance.preview.shown).toBe(false); - }); - - describe('panel DOM element set up', () => { - it('sets up an element to contain the preview and stores it on instance', () => { - expect(instance.preview.el).toBeUndefined(); - - instance.togglePreview(); - - expect(instance.preview.el).toBeDefined(); - expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe( - true, - ); - }); - - it('re-uses existing preview DOM element on repeated calls', () => { - instance.togglePreview(); - const origPreviewEl = instance.preview.el; - instance.togglePreview(); - - expect(instance.preview.el).toBe(origPreviewEl); - }); - - it('hides the preview DOM element by default', () => { - panelSpy.mockImplementation(); - instance.togglePreview(); - expect(instance.preview.el.style.display).toBe('none'); - }); - }); - - describe('preview layout setup', () => { - it('sets correct preview layout', () => { - jest.spyOn(instance, 'layout'); - const { width, height } = instance.getLayoutInfo(); - - instance.togglePreview(); - - expect(instance.layout).toHaveBeenCalledWith({ - width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, - height, - }); - }); - }); - - describe('preview panel', () => { - it('toggles preview CSS class on the editor', () => { - expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( - false, - ); - instance.togglePreview(); - expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( - true, - ); - instance.togglePreview(); - expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( - false, - ); - }); - - it('toggles visibility of the preview DOM element', async () => { - await togglePreview(); - expect(instance.preview.el.style.display).toBe('block'); - await togglePreview(); - expect(instance.preview.el.style.display).toBe('none'); - }); - - describe('hidden preview DOM element', () => { - it('listens to model changes and re-fetches preview', async () => { - expect(mockAxios.history.post).toHaveLength(0); - await togglePreview(); - expect(mockAxios.history.post).toHaveLength(1); - - instance.setValue('New Value'); - await waitForPromises(); - expect(mockAxios.history.post).toHaveLength(2); - }); - - it('stores disposable listener for model changes', async () => { - expect(instance.preview.modelChangeListener).toBeUndefined(); - await togglePreview(); - expect(instance.preview.modelChangeListener).toBeDefined(); - }); - }); - - describe('already visible preview', () => { - beforeEach(async () => { - await togglePreview(); - mockAxios.resetHistory(); - }); - - it('does not re-fetch the preview', () => { - instance.togglePreview(); - expect(mockAxios.history.post).toHaveLength(0); - }); - - it('disposes the model change event listener', () => { - const disposeSpy = jest.fn(); - instance.preview.modelChangeListener = { - dispose: disposeSpy, - }; - instance.togglePreview(); - expect(disposeSpy).toHaveBeenCalled(); - }); - }); - }); - }); - describe('getSelectedText', () => { it('does not fail if there is no selection and returns the empty string', () => { jest.spyOn(instance, 'getSelection'); diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js new file mode 100644 index 00000000000..3d797073c05 --- /dev/null +++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js @@ -0,0 +1,398 @@ +import MockAdapter from 'axios-mock-adapter'; +import { editor as monacoEditor } from 'monaco-editor'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, +} from '~/editor/constants'; +import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; +import SourceEditor from '~/editor/source_editor'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import syntaxHighlight from '~/syntax_highlight'; + +jest.mock('~/syntax_highlight'); +jest.mock('~/flash'); + +describe('Markdown Live Preview Extension for Source Editor', () => { + let editor; + let instance; + let editorEl; + let panelSpy; + let mockAxios; + const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown'; + const firstLine = 'This is a'; + const secondLine = 'multiline'; + const thirdLine = 'string with some **markup**'; + const text = `${firstLine}\n${secondLine}\n${thirdLine}`; + const plaintextPath = 'foo.txt'; + const markdownPath = 'foo.md'; + const responseData = '<div>FooBar</div>'; + + const togglePreview = async () => { + instance.togglePreview(); + await waitForPromises(); + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + setFixtures('<div id="editor" data-editor-loading></div>'); + editorEl = document.getElementById('editor'); + editor = new SourceEditor(); + instance = editor.createInstance({ + el: editorEl, + blobPath: markdownPath, + blobContent: text, + }); + instance.use(new EditorMarkdownPreviewExtension({ instance, previewMarkdownPath })); + panelSpy = jest.spyOn(EditorMarkdownPreviewExtension, 'togglePreviewPanel'); + }); + + afterEach(() => { + instance.dispose(); + editorEl.remove(); + mockAxios.restore(); + }); + + it('sets up the instance', () => { + expect(instance.preview).toEqual({ + el: undefined, + action: expect.any(Object), + shown: false, + modelChangeListener: undefined, + }); + expect(instance.previewMarkdownPath).toBe(previewMarkdownPath); + }); + + describe('model language changes listener', () => { + let cleanupSpy; + let actionSpy; + + beforeEach(async () => { + cleanupSpy = jest.spyOn(instance, 'cleanup'); + actionSpy = jest.spyOn(instance, 'setupPreviewAction'); + await togglePreview(); + }); + + it('cleans up when switching away from markdown', () => { + expect(instance.cleanup).not.toHaveBeenCalled(); + expect(instance.setupPreviewAction).not.toHaveBeenCalled(); + + instance.updateModelLanguage(plaintextPath); + + expect(cleanupSpy).toHaveBeenCalled(); + expect(actionSpy).not.toHaveBeenCalled(); + }); + + it.each` + oldLanguage | newLanguage | setupCalledTimes + ${'plaintext'} | ${'markdown'} | ${1} + ${'markdown'} | ${'markdown'} | ${0} + ${'markdown'} | ${'plaintext'} | ${0} + ${'markdown'} | ${undefined} | ${0} + ${undefined} | ${'markdown'} | ${1} + `( + 'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage', + ({ oldLanguage, newLanguage, setupCalledTimes } = {}) => { + expect(actionSpy).not.toHaveBeenCalled(); + instance.updateModelLanguage(oldLanguage); + instance.updateModelLanguage(newLanguage); + expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); + }, + ); + }); + + describe('model change listener', () => { + let cleanupSpy; + let actionSpy; + + beforeEach(() => { + cleanupSpy = jest.spyOn(instance, 'cleanup'); + actionSpy = jest.spyOn(instance, 'setupPreviewAction'); + instance.togglePreview(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not do anything if there is no model', () => { + instance.setModel(null); + + expect(cleanupSpy).not.toHaveBeenCalled(); + expect(actionSpy).not.toHaveBeenCalled(); + }); + + it('cleans up the preview when the model changes', () => { + instance.setModel(monacoEditor.createModel('foo')); + expect(cleanupSpy).toHaveBeenCalled(); + }); + + it.each` + language | setupCalledTimes + ${'markdown'} | ${1} + ${'plaintext'} | ${0} + ${undefined} | ${0} + `( + 'correctly handles actions when the new model is $language', + ({ language, setupCalledTimes } = {}) => { + instance.setModel(monacoEditor.createModel('foo', language)); + + expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); + }, + ); + }); + + describe('cleanup', () => { + beforeEach(async () => { + mockAxios.onPost().reply(200, { body: responseData }); + await togglePreview(); + }); + + it('disposes the modelChange listener and does not fetch preview on content changes', () => { + expect(instance.preview.modelChangeListener).toBeDefined(); + jest.spyOn(instance, 'fetchPreview'); + + instance.cleanup(); + instance.setValue('Foo Bar'); + jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY); + + expect(instance.fetchPreview).not.toHaveBeenCalled(); + }); + + it('removes the contextual menu action', () => { + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); + + instance.cleanup(); + + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null); + }); + + it('toggles the `shown` flag', () => { + expect(instance.preview.shown).toBe(true); + instance.cleanup(); + expect(instance.preview.shown).toBe(false); + }); + + it('toggles the panel only if the preview is visible', () => { + const { el: previewEl } = instance.preview; + const parentEl = previewEl.parentElement; + + expect(previewEl).toBeVisible(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true); + + instance.cleanup(); + expect(previewEl).toBeHidden(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + + instance.cleanup(); + expect(previewEl).toBeHidden(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + }); + + it('toggles the layout only if the preview is visible', () => { + const { width } = instance.getLayoutInfo(); + + expect(instance.preview.shown).toBe(true); + + instance.cleanup(); + + const { width: newWidth } = instance.getLayoutInfo(); + expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); + + instance.cleanup(); + expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); + }); + }); + + describe('fetchPreview', () => { + const fetchPreview = async () => { + instance.fetchPreview(); + await waitForPromises(); + }; + + let previewMarkdownSpy; + + beforeEach(() => { + previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]); + mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req)); + }); + + it('correctly fetches preview based on previewMarkdownPath', async () => { + await fetchPreview(); + + expect(previewMarkdownSpy).toHaveBeenCalledWith( + expect.objectContaining({ data: JSON.stringify({ text }) }), + ); + }); + + it('puts the fetched content into the preview DOM element', async () => { + instance.preview.el = editorEl.parentElement; + await fetchPreview(); + expect(instance.preview.el.innerHTML).toEqual(responseData); + }); + + it('applies syntax highlighting to the preview content', async () => { + instance.preview.el = editorEl.parentElement; + await fetchPreview(); + expect(syntaxHighlight).toHaveBeenCalled(); + }); + + it('catches the errors when fetching the preview', async () => { + mockAxios.onPost().reply(500); + + await fetchPreview(); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('setupPreviewAction', () => { + it('adds the contextual menu action', () => { + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); + }); + + it('does not set up action if one already exists', () => { + jest.spyOn(instance, 'addAction').mockImplementation(); + + instance.setupPreviewAction(); + expect(instance.addAction).not.toHaveBeenCalled(); + }); + + it('toggles preview when the action is triggered', () => { + jest.spyOn(instance, 'togglePreview').mockImplementation(); + + expect(instance.togglePreview).not.toHaveBeenCalled(); + + const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID); + action.run(); + + expect(instance.togglePreview).toHaveBeenCalled(); + }); + }); + + describe('togglePreview', () => { + beforeEach(() => { + mockAxios.onPost().reply(200, { body: responseData }); + }); + + it('toggles preview flag on instance', () => { + expect(instance.preview.shown).toBe(false); + + instance.togglePreview(); + expect(instance.preview.shown).toBe(true); + + instance.togglePreview(); + expect(instance.preview.shown).toBe(false); + }); + + describe('panel DOM element set up', () => { + it('sets up an element to contain the preview and stores it on instance', () => { + expect(instance.preview.el).toBeUndefined(); + + instance.togglePreview(); + + expect(instance.preview.el).toBeDefined(); + expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe( + true, + ); + }); + + it('re-uses existing preview DOM element on repeated calls', () => { + instance.togglePreview(); + const origPreviewEl = instance.preview.el; + instance.togglePreview(); + + expect(instance.preview.el).toBe(origPreviewEl); + }); + + it('hides the preview DOM element by default', () => { + panelSpy.mockImplementation(); + instance.togglePreview(); + expect(instance.preview.el.style.display).toBe('none'); + }); + }); + + describe('preview layout setup', () => { + it('sets correct preview layout', () => { + jest.spyOn(instance, 'layout'); + const { width, height } = instance.getLayoutInfo(); + + instance.togglePreview(); + + expect(instance.layout).toHaveBeenCalledWith({ + width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + height, + }); + }); + }); + + describe('preview panel', () => { + it('toggles preview CSS class on the editor', () => { + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + instance.togglePreview(); + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + true, + ); + instance.togglePreview(); + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + }); + + it('toggles visibility of the preview DOM element', async () => { + await togglePreview(); + expect(instance.preview.el.style.display).toBe('block'); + await togglePreview(); + expect(instance.preview.el.style.display).toBe('none'); + }); + + describe('hidden preview DOM element', () => { + it('listens to model changes and re-fetches preview', async () => { + expect(mockAxios.history.post).toHaveLength(0); + await togglePreview(); + expect(mockAxios.history.post).toHaveLength(1); + + instance.setValue('New Value'); + await waitForPromises(); + expect(mockAxios.history.post).toHaveLength(2); + }); + + it('stores disposable listener for model changes', async () => { + expect(instance.preview.modelChangeListener).toBeUndefined(); + await togglePreview(); + expect(instance.preview.modelChangeListener).toBeDefined(); + }); + }); + + describe('already visible preview', () => { + beforeEach(async () => { + await togglePreview(); + mockAxios.resetHistory(); + }); + + it('does not re-fetch the preview', () => { + instance.togglePreview(); + expect(mockAxios.history.post).toHaveLength(0); + }); + + it('disposes the model change event listener', () => { + const disposeSpy = jest.fn(); + instance.preview.modelChangeListener = { + dispose: disposeSpy, + }; + instance.togglePreview(); + expect(disposeSpy).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/spec/helpers/notify_helper_spec.rb b/spec/helpers/notify_helper_spec.rb index a4193444528..633a4b65139 100644 --- a/spec/helpers/notify_helper_spec.rb +++ b/spec/helpers/notify_helper_spec.rb @@ -70,28 +70,6 @@ RSpec.describe NotifyHelper do expect(helper.invited_join_url(token, member)) .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_preview_text&invite_type=initial_email") end - - context 'when invite_email_from is enabled' do - before do - stub_experiments(invite_email_from: :control) - end - - it 'has correct params' do - expect(helper.invited_join_url(token, member)) - .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email") - end - end - end - - context 'when invite_email_from is enabled' do - before do - stub_experiments(invite_email_from: :control) - end - - it 'has correct params' do - expect(helper.invited_join_url(token, member)) - .to eq("http://test.host/-/invites/#{token}?experiment_name=invite_email_from&invite_type=initial_email") - end end context 'when invite_email_preview_text is disabled' do diff --git a/spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb new file mode 100644 index 00000000000..28bc685286f --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/create_deployments_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Chain::CreateDeployments do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + let(:stage) { build(:ci_stage_entity, project: project, statuses: [job]) } + let(:pipeline) { create(:ci_pipeline, project: project, stages: [stage]) } + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new(project: project, current_user: user) + end + + let(:step) { described_class.new(pipeline, command) } + + describe '#perform!' do + subject { step.perform! } + + before do + job.pipeline = pipeline + end + + context 'when a pipeline contains a deployment job' do + let!(:job) { build(:ci_build, :start_review_app, project: project) } + let!(:environment) { create(:environment, project: project, name: job.expanded_environment_name) } + + it 'creates a deployment record' do + expect { subject }.to change { Deployment.count }.by(1) + + job.reset + expect(job.deployment.project).to eq(job.project) + expect(job.deployment.ref).to eq(job.ref) + expect(job.deployment.sha).to eq(job.sha) + expect(job.deployment.deployable).to eq(job) + expect(job.deployment.deployable_type).to eq('CommitStatus') + expect(job.deployment.environment).to eq(job.persisted_environment) + 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 + let!(:environment) { } + + it 'does not create a deployment record' do + expect { subject }.not_to change { Deployment.count } + + expect(job.deployment).to be_nil + end + end + + context 'when create_deployment_in_separate_transaction feature flag is disabled' do + before do + stub_feature_flags(create_deployment_in_separate_transaction: false) + end + + it 'does not create a deployment record' do + expect { subject }.not_to change { Deployment.count } + + expect(job.deployment).to be_nil + end + end + end + + context 'when a pipeline contains a teardown job' do + let!(:job) { build(:ci_build, :stop_review_app, project: project) } + let!(:environment) { create(:environment, name: job.expanded_environment_name) } + + it 'does not create a deployment record' do + expect { subject }.not_to change { Deployment.count } + + expect(job.deployment).to be_nil + end + end + + context 'when a pipeline does not contain a deployment job' do + let!(:job) { build(:ci_build, project: project) } + + it 'does not create any deployments' do + expect { subject }.not_to change { Deployment.count } + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/ensure_environments_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/ensure_environments_spec.rb new file mode 100644 index 00000000000..253928e1a19 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/ensure_environments_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Chain::EnsureEnvironments do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:stage) { build(:ci_stage_entity, project: project, statuses: [job]) } + let(:pipeline) { build(:ci_pipeline, project: project, stages: [stage]) } + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new(project: project, current_user: user) + end + + let(:step) { described_class.new(pipeline, command) } + + describe '#perform!' do + subject { step.perform! } + + before do + job.pipeline = pipeline + end + + context 'when a pipeline contains a deployment job' do + let!(:job) { build(:ci_build, :start_review_app, project: project) } + + it 'ensures environment existence for the job' do + expect { subject }.to change { Environment.count }.by(1) + + expect(project.environments.find_by_name('review/master')).to be_present + expect(job.persisted_environment.name).to eq('review/master') + expect(job.metadata.expanded_environment_name).to eq('review/master') + end + + context 'when an environment has already been existed' do + before do + create(:environment, project: project, name: 'review/master') + end + + it 'ensures environment existence for the job' do + expect { subject }.not_to change { Environment.count } + + expect(project.environments.find_by_name('review/master')).to be_present + expect(job.persisted_environment.name).to eq('review/master') + expect(job.metadata.expanded_environment_name).to eq('review/master') + end + end + + context 'when an environment name contains an invalid character' do + let(:pipeline) { build(:ci_pipeline, ref: '!!!', project: project, stages: [stage]) } + + it 'sets the failure status' do + expect { subject }.not_to change { Environment.count } + + expect(job).to be_failed + expect(job).to be_environment_creation_failure + expect(job.persisted_environment).to be_nil + end + end + + context 'when create_deployment_in_separate_transaction feature flag is disabled' do + before do + stub_feature_flags(create_deployment_in_separate_transaction: false) + end + + it 'does not create any environments' do + expect { subject }.not_to change { Environment.count } + + expect(job.persisted_environment).to be_nil + end + end + end + + context 'when a pipeline contains a teardown job' do + let!(:job) { build(:ci_build, :stop_review_app, project: project) } + + it 'ensures environment existence for the job' do + expect { subject }.to change { Environment.count }.by(1) + + expect(project.environments.find_by_name('review/master')).to be_present + expect(job.persisted_environment.name).to eq('review/master') + expect(job.metadata.expanded_environment_name).to eq('review/master') + end + end + + context 'when a pipeline does not contain a deployment job' do + let!(:job) { build(:ci_build, project: project) } + + it 'does not create any environments' do + expect { subject }.not_to change { Environment.count } + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/ensure_resource_groups_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/ensure_resource_groups_spec.rb new file mode 100644 index 00000000000..87df5a3e21b --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/ensure_resource_groups_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:stage) { build(:ci_stage_entity, project: project, statuses: [job]) } + let(:pipeline) { build(:ci_pipeline, project: project, stages: [stage]) } + let!(:environment) { create(:environment, name: 'production', project: project) } + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new(project: project, current_user: user) + end + + let(:step) { described_class.new(pipeline, command) } + + describe '#perform!' do + subject { step.perform! } + + before do + job.pipeline = pipeline + end + + context 'when a pipeline contains a job that requires a resource group' do + let!(:job) do + build(:ci_build, project: project, environment: 'production', options: { resource_group_key: '$CI_ENVIRONMENT_NAME' }) + end + + it 'ensures the resource group existence' do + expect { subject }.to change { Ci::ResourceGroup.count }.by(1) + + expect(project.resource_groups.find_by_key('production')).to be_present + expect(job.resource_group.key).to eq('production') + expect(job.options[:resource_group_key]).to be_nil + end + + context 'when a resource group has already been existed' do + before do + create(:ci_resource_group, project: project, key: 'production') + end + + it 'ensures the resource group existence' do + expect { subject }.not_to change { Ci::ResourceGroup.count } + + expect(project.resource_groups.find_by_key('production')).to be_present + expect(job.resource_group.key).to eq('production') + expect(job.options[:resource_group_key]).to be_nil + end + end + + context 'when a resource group key contains an invalid character' do + let!(:job) do + build(:ci_build, project: project, environment: '!!!', options: { resource_group_key: '$CI_ENVIRONMENT_NAME' }) + end + + it 'does not create any resource groups' do + expect { subject }.not_to change { Ci::ResourceGroup.count } + + expect(job.resource_group).to be_nil + end + end + + context 'when create_deployment_in_separate_transaction feature flag is disabled' do + before do + stub_feature_flags(create_deployment_in_separate_transaction: false) + end + + it 'does not create any resource groups' do + expect { subject }.not_to change { Ci::ResourceGroup.count } + + expect(job.resource_group).to be_nil + end + end + end + + context 'when a pipeline does not contain a job that requires a resource group' do + let!(:job) { build(:ci_build, project: project) } + + it 'does not create any resource groups' do + expect { subject }.not_to change { Ci::ResourceGroup.count } + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 73d827085b8..c53f1be1057 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -393,6 +393,10 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do describe '#to_resource' do subject { seed_build.to_resource } + before do + stub_feature_flags(create_deployment_in_separate_transaction: false) + end + context 'when job is Ci::Build' do it { is_expected.to be_a(::Ci::Build) } it { is_expected.to be_valid } @@ -443,6 +447,18 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do it_behaves_like 'deployment job' it_behaves_like 'ensures environment existence' + context 'when create_deployment_in_separate_transaction feature flag is enabled' do + before do + stub_feature_flags(create_deployment_in_separate_transaction: true) + end + + it 'does not create any deployments nor environments' do + expect(subject.deployment).to be_nil + expect(Environment.count).to eq(0) + expect(Deployment.count).to eq(0) + end + end + context 'when the environment name is invalid' do let(:attributes) { { name: 'deploy', ref: 'master', environment: '!!!' } } @@ -496,6 +512,18 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Build do it 'returns a job with resource group' do expect(subject.resource_group).not_to be_nil expect(subject.resource_group.key).to eq('iOS') + expect(Ci::ResourceGroup.count).to eq(1) + end + + context 'when create_deployment_in_separate_transaction feature flag is enabled' do + before do + stub_feature_flags(create_deployment_in_separate_transaction: true) + end + + it 'does not create any resource groups' do + expect(subject.resource_group).to be_nil + expect(Ci::ResourceGroup.count).to eq(0) + end end context 'when resource group has $CI_ENVIRONMENT_NAME in it' do diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index c3c35279fe8..66cb95a59a6 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -199,7 +199,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do for_defined_days_back do user = create(:user) user2 = create(:user) - create(:event, author: user) create(:group_member, user: user) create(:authentication_event, user: user, provider: :ldapmain, result: :success) create(:authentication_event, user: user2, provider: :ldapsecondary, result: :success) @@ -208,17 +207,24 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do create(:authentication_event, user: user, provider: :group_saml, result: :failed) end + for_defined_days_back(days: [31, 29, 3]) do + create(:event) + end + + stub_const('Gitlab::Database::PostgresHll::BatchDistinctCounter::DEFAULT_BATCH_SIZE', 1) + stub_const('Gitlab::Database::PostgresHll::BatchDistinctCounter::MIN_REQUIRED_BATCH_SIZE', 0) + expect(described_class.usage_activity_by_stage_manage({})).to include( events: -1, groups: 2, - users_created: 6, + users_created: 10, omniauth_providers: ['google_oauth2'], user_auth_by_provider: { 'group_saml' => 2, 'ldap' => 4, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 } ) expect(described_class.usage_activity_by_stage_manage(described_class.monthly_time_range_db_params)).to include( - events: be_within(error_rate).percent_of(1), + events: be_within(error_rate).percent_of(2), groups: 1, - users_created: 3, + users_created: 6, omniauth_providers: ['google_oauth2'], user_auth_by_provider: { 'group_saml' => 1, 'ldap' => 2, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 } ) diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 098ac21eb18..ad9bd0faf8a 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -888,27 +888,6 @@ RSpec.describe Notify do end end - context 'with invite_email_from enabled', :experiment do - before do - stub_experiments(invite_email_from: :control) - end - - it 'has the correct invite_url with params' do - is_expected.to have_link('Join now', - href: invite_url(project_member.invite_token, - invite_type: Emails::Members::INITIAL_INVITE, - experiment_name: 'invite_email_from')) - end - - it 'tracks the sent invite' do - expect(experiment(:invite_email_from)).to track(:assignment) - .with_context(actor: project_member) - .on_next_instance - - invite_email.deliver_now - end - end - context 'when invite email sent is tracked', :snowplow do it 'tracks the sent invite' do invite_email.deliver_now diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9aa67057fca..730d7f02424 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -3525,19 +3525,7 @@ RSpec.describe User do subject { user.membership_groups } - shared_examples 'returns groups where the user is a member' do - specify { is_expected.to contain_exactly(parent_group, child_group) } - end - - it_behaves_like 'returns groups where the user is a member' - - context 'when feature flag :linear_user_membership_groups is disabled' do - before do - stub_feature_flags(linear_user_membership_groups: false) - end - - it_behaves_like 'returns groups where the user is a member' - end + specify { is_expected.to contain_exactly(parent_group, child_group) } end describe '#authorizations_for_projects' do diff --git a/workhorse/internal/api/api_test.go b/workhorse/internal/api/api_test.go index 43e3604cc9c..b82bb55fb85 100644 --- a/workhorse/internal/api/api_test.go +++ b/workhorse/internal/api/api_test.go @@ -49,7 +49,7 @@ func getGeoProxyURLGivenResponse(t *testing.T, givenInternalApiResponse string) } func testRailsServer(url *regexp.Regexp, code int, body string) *httptest.Server { - return testhelper.TestServerWithHandler(url, func(w http.ResponseWriter, r *http.Request) { + return testhelper.TestServerWithHandlerWithGeoPolling(url, func(w http.ResponseWriter, r *http.Request) { // return a 204 No Content response if we don't receive the JWT header if r.Header.Get(secret.RequestHeader) == "" { w.WriteHeader(204) diff --git a/workhorse/internal/testhelper/testhelper.go b/workhorse/internal/testhelper/testhelper.go index 7e66563e438..dae8f9b3149 100644 --- a/workhorse/internal/testhelper/testhelper.go +++ b/workhorse/internal/testhelper/testhelper.go @@ -22,6 +22,10 @@ import ( "gitlab.com/gitlab-org/gitlab/workhorse/internal/secret" ) +const ( + geoProxyEndpointPath = "/api/v4/geo/proxy" +) + func ConfigureSecret() { secret.SetPath(path.Join(RootDir(), "testdata/test-secret")) } @@ -50,7 +54,20 @@ func RequireResponseHeader(t *testing.T, w interface{}, header string, expected require.Equal(t, expected, actual, "values for HTTP header %s", header) } +// TestServerWithHandler skips Geo API polling for a proxy URL by default, +// use TestServerWithHandlerWithGeoPolling if you need to explicitly +// handle Geo API polling request as well. func TestServerWithHandler(url *regexp.Regexp, handler http.HandlerFunc) *httptest.Server { + return TestServerWithHandlerWithGeoPolling(url, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == geoProxyEndpointPath { + return + } + + handler(w, r) + })) +} + +func TestServerWithHandlerWithGeoPolling(url *regexp.Regexp, handler http.HandlerFunc) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logEntry := log.WithFields(log.Fields{ "method": r.Method, diff --git a/workhorse/internal/upstream/upstream.go b/workhorse/internal/upstream/upstream.go index 6835569dfa8..99d1245fafc 100644 --- a/workhorse/internal/upstream/upstream.go +++ b/workhorse/internal/upstream/upstream.go @@ -207,8 +207,8 @@ func (u *upstream) findGeoProxyRoute(cleanedPath string, r *http.Request) *route func (u *upstream) pollGeoProxyAPI() { for { - u.geoProxyPollSleep(geoProxyApiPollingInterval) u.callGeoProxyAPI() + u.geoProxyPollSleep(geoProxyApiPollingInterval) } } diff --git a/workhorse/internal/upstream/upstream_test.go b/workhorse/internal/upstream/upstream_test.go index c685f82c388..2031fa84ce2 100644 --- a/workhorse/internal/upstream/upstream_test.go +++ b/workhorse/internal/upstream/upstream_test.go @@ -314,9 +314,5 @@ func startWorkhorseServer(railsServerURL string, enableGeoProxyFeature bool) (*h } } - // Since the first sleep happens before any API call, this ensures - // we call the API at least once. - waitForNextApiPoll() - return ws, ws.Close, waitForNextApiPoll } diff --git a/workhorse/sendfile_test.go b/workhorse/sendfile_test.go index 2408f4fde38..0a01e410d39 100644 --- a/workhorse/sendfile_test.go +++ b/workhorse/sendfile_test.go @@ -5,13 +5,15 @@ import ( "io/ioutil" "mime" "net/http" - "net/http/httptest" "os" "path" + "regexp" "testing" "github.com/stretchr/testify/require" "gitlab.com/gitlab-org/labkit/log" + + "gitlab.com/gitlab-org/gitlab/workhorse/internal/testhelper" ) func TestDeniedLfsDownload(t *testing.T) { @@ -35,7 +37,7 @@ func allowedXSendfileDownload(t *testing.T, contentFilename string, filePath str prepareDownloadDir(t) // Prepare test server and backend - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := testhelper.TestServerWithHandler(regexp.MustCompile(`.`), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.WithFields(log.Fields{"method": r.Method, "url": r.URL}).Info("UPSTREAM") require.Equal(t, "X-Sendfile", r.Header.Get("X-Sendfile-Type")) @@ -69,7 +71,7 @@ func deniedXSendfileDownload(t *testing.T, contentFilename string, filePath stri prepareDownloadDir(t) // Prepare test server and backend - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := testhelper.TestServerWithHandler(regexp.MustCompile(`.`), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.WithFields(log.Fields{"method": r.Method, "url": r.URL}).Info("UPSTREAM") require.Equal(t, "X-Sendfile", r.Header.Get("X-Sendfile-Type")) |