diff --git a/app/assets/javascripts/boards/eventhub.js b/app/assets/javascripts/boards/eventhub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/boards/eventhub.js
+++ b/app/assets/javascripts/boards/eventhub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js
new file mode 100644
index 00000000000..ceeb71c4eec
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js
@@ -0,0 +1,27 @@
+import { __ } from '~/locale';
+import FilteredSearchTokenKeys from './filtered_search_token_keys';
+
+const tokenKeys = [
+ {
+ formattedKey: __('Status'),
+ key: 'status',
+ type: 'string',
+ param: 'status',
+ symbol: '',
+ icon: 'messages',
+ tag: 'status',
+ },
+ {
+ formattedKey: __('Type'),
+ key: 'type',
+ type: 'string',
+ param: 'type',
+ symbol: '',
+ icon: 'cube',
+ tag: 'type',
+ },
+];
+
+const GroupRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys);
+
+export default GroupRunnersFilteredSearchTokenKeys;
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
index c48870be556..60558c0ba14 100644
--- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -294,6 +294,14 @@ export default {
data-qa-selector="alert_threshold_field"
/>
+
+
+
{
@@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.ADMIN_RUNNERS,
- filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
+ filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys,
anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR,
});
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index a1a9489e659..5abf8e1c64f 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -224,12 +224,6 @@
font-size: $gl-font-size-large;
}
}
-
- .notifications-btn {
- .fa-bell {
- margin-right: 0;
- }
- }
}
.nav > .project-buttons {
diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb
index a7edfea5c09..d13735f3e79 100644
--- a/app/controllers/projects/metrics_dashboard_controller.rb
+++ b/app/controllers/projects/metrics_dashboard_controller.rb
@@ -10,6 +10,7 @@ module Projects
before_action do
push_frontend_feature_flag(:prometheus_computed_alerts)
push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate)
+ push_frontend_feature_flag(:alert_runbooks)
end
def show
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index c5809c11ea9..085bebd225d 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -42,8 +42,8 @@ module Projects
# Technical Debt: https://gitlab.com/gitlab-org/gitlab/issues/207267
# name_regex to be removed when container_expiration_policies is updated
# to have both regex columns
- regex_delete = Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_delete'] || params['name_regex']}\\z")
- regex_retain = Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_keep']}\\z")
+ regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_delete'] || params['name_regex']}\\z")
+ regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_keep']}\\z")
tags.select do |tag|
# regex_retain will override any overlapping matches by regex_delete
@@ -81,11 +81,11 @@ module Projects
def valid_regex?
%w(name_regex_delete name_regex name_regex_keep).each do |param_name|
regex = params[param_name]
- Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
+ ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank?
end
true
rescue RegexpError => e
- Gitlab::ErrorTracking.log_exception(e, project_id: project.id)
+ ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id)
false
end
end
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index 5d4059710bb..a23a6a369b2 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -6,65 +6,35 @@ module Projects
LOG_DATA_BASE = { service_class: self.to_s }.freeze
def execute(container_repository)
+ @container_repository = container_repository
return error('access denied') unless can?(current_user, :destroy_container_image, project)
- tag_names = params[:tags]
- return error('not tags specified') if tag_names.blank?
+ @tag_names = params[:tags]
+ return error('not tags specified') if @tag_names.blank?
- smart_delete(container_repository, tag_names)
+ delete_tags
end
private
- # Delete tags by name with a single DELETE request. This is only supported
- # by the GitLab Container Registry fork. See
- # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23325 for details.
- def fast_delete(container_repository, tag_names)
- deleted_tags = tag_names.select do |name|
- container_repository.delete_tag_by_name(name)
- end
-
- deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
+ def delete_tags
+ delete_service.execute
+ .tap(&method(:log_response))
end
- # Replace a tag on the registry with a dummy tag.
- # This is a hack as the registry doesn't support deleting individual
- # tags. This code effectively pushes a dummy image and assigns the tag to it.
- # This way when the tag is deleted only the dummy image is affected.
- # This is used to preverse compatibility with third-party registries that
- # don't support fast delete.
- # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
- def slow_delete(container_repository, tag_names)
- # generates the blobs for the dummy image
- dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path)
- return error('could not generate manifest') if dummy_manifest.nil?
-
- deleted_tags = replace_tag_manifests(container_repository, dummy_manifest, tag_names)
-
- # Deletes the dummy image
- # All created tag digests are the same since they all have the same dummy image.
- # a single delete is sufficient to remove all tags with it
- if deleted_tags.any? && container_repository.delete_tag_by_digest(deleted_tags.each_value.first)
- success(deleted: deleted_tags.keys)
- else
- error('could not delete tags')
- end
- end
-
- def smart_delete(container_repository, tag_names)
+ def delete_service
fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true)
- response = if fast_delete_enabled && container_repository.client.supports_tag_delete?
- fast_delete(container_repository, tag_names)
- else
- slow_delete(container_repository, tag_names)
- end
- response.tap { |r| log_response(r, container_repository) }
+ if fast_delete_enabled && @container_repository.client.supports_tag_delete?
+ ::Projects::ContainerRepository::Gitlab::DeleteTagsService.new(@container_repository, @tag_names)
+ else
+ ::Projects::ContainerRepository::ThirdParty::DeleteTagsService.new(@container_repository, @tag_names)
+ end
end
- def log_response(response, container_repository)
+ def log_response(response)
log_data = LOG_DATA_BASE.merge(
- container_repository_id: container_repository.id,
+ container_repository_id: @container_repository.id,
message: 'deleted tags'
)
@@ -76,26 +46,6 @@ module Projects
log_error(log_data)
end
end
-
- # update the manifests of the tags with the new dummy image
- def replace_tag_manifests(container_repository, dummy_manifest, tag_names)
- deleted_tags = {}
-
- tag_names.each do |name|
- digest = container_repository.client.put_tag(container_repository.path, name, dummy_manifest)
- next unless digest
-
- deleted_tags[name] = digest
- end
-
- # make sure the digests are the same (it should always be)
- digests = deleted_tags.values.uniq
-
- # rubocop: disable CodeReuse/ActiveRecord
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new('multiple tag digests')) if digests.many?
-
- deleted_tags
- end
end
end
end
diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
new file mode 100644
index 00000000000..18049648e26
--- /dev/null
+++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ module Gitlab
+ class DeleteTagsService
+ include BaseServiceUtility
+
+ def initialize(container_repository, tag_names)
+ @container_repository = container_repository
+ @tag_names = tag_names
+ end
+
+ # Delete tags by name with a single DELETE request. This is only supported
+ # by the GitLab Container Registry fork. See
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23325 for details.
+ def execute
+ return success(deleted: []) if @tag_names.empty?
+
+ deleted_tags = @tag_names.select do |name|
+ @container_repository.delete_tag_by_name(name)
+ end
+
+ deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb
new file mode 100644
index 00000000000..6504172109e
--- /dev/null
+++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ module ThirdParty
+ class DeleteTagsService
+ include BaseServiceUtility
+
+ def initialize(container_repository, tag_names)
+ @container_repository = container_repository
+ @tag_names = tag_names
+ end
+
+ # Replace a tag on the registry with a dummy tag.
+ # This is a hack as the registry doesn't support deleting individual
+ # tags. This code effectively pushes a dummy image and assigns the tag to it.
+ # This way when the tag is deleted only the dummy image is affected.
+ # This is used to preverse compatibility with third-party registries that
+ # don't support fast delete.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
+ def execute
+ return success(deleted: []) if @tag_names.empty?
+
+ # generates the blobs for the dummy image
+ dummy_manifest = @container_repository.client.generate_empty_manifest(@container_repository.path)
+ return error('could not generate manifest') if dummy_manifest.nil?
+
+ deleted_tags = replace_tag_manifests(dummy_manifest)
+
+ # Deletes the dummy image
+ # All created tag digests are the same since they all have the same dummy image.
+ # a single delete is sufficient to remove all tags with it
+ if deleted_tags.any? && @container_repository.delete_tag_by_digest(deleted_tags.each_value.first)
+ success(deleted: deleted_tags.keys)
+ else
+ error('could not delete tags')
+ end
+ end
+
+ private
+
+ # update the manifests of the tags with the new dummy image
+ def replace_tag_manifests(dummy_manifest)
+ deleted_tags = {}
+
+ @tag_names.each do |name|
+ digest = @container_repository.client.put_tag(@container_repository.path, name, dummy_manifest)
+ next unless digest
+
+ deleted_tags[name] = digest
+ end
+
+ # make sure the digests are the same (it should always be)
+ digests = deleted_tags.values.uniq
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new('multiple tag digests')) if digests.many?
+
+ deleted_tags
+ end
+ end
+ end
+ end
+end
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index 2b3e986a841..b4a274a3ecf 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -17,16 +17,16 @@
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
- = icon("bell", class: "js-notification-loading")
+ %button.dropdown-new.btn.btn-defaul.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
+ = sprite_icon("notifications", size: 16, css_class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
= icon('caret-down')
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
.float-left
- = icon("bell", class: "js-notification-loading")
+ = sprite_icon("notifications", size: 16, css_class: "js-notification-loading")
= notification_title(notification_setting.level)
.float-right
= icon("caret-down")
diff --git a/app/views/shared/projects/_archived.html.haml b/app/views/shared/projects/_archived.html.haml
index fad93d14390..f24fe3a8b89 100644
--- a/app/views/shared/projects/_archived.html.haml
+++ b/app/views/shared/projects/_archived.html.haml
@@ -1,3 +1,3 @@
- if project.archived
- %span.d-flex.badge.badge-warning
+ %span.d-flex.badge-pill.gl-badge.badge-warning.gl-ml-3
= _('archived')
diff --git a/changelogs/unreleased/225934-replace-fa-bell-icons-with-gitlab-svg-notifications-icon.yml b/changelogs/unreleased/225934-replace-fa-bell-icons-with-gitlab-svg-notifications-icon.yml
new file mode 100644
index 00000000000..ea7b5a91be4
--- /dev/null
+++ b/changelogs/unreleased/225934-replace-fa-bell-icons-with-gitlab-svg-notifications-icon.yml
@@ -0,0 +1,5 @@
+---
+title: Replace fa-bell icons with GitLab SVG notifications icon
+merge_request: 37676
+author:
+type: changed
diff --git a/changelogs/unreleased/fix-filtered-search-group-runners-list-view.yml b/changelogs/unreleased/fix-filtered-search-group-runners-list-view.yml
new file mode 100644
index 00000000000..44cffc7cb33
--- /dev/null
+++ b/changelogs/unreleased/fix-filtered-search-group-runners-list-view.yml
@@ -0,0 +1,5 @@
+---
+title: Fix bug in group runners filtered search
+merge_request: 37626
+author: Arthur de Lapertosa Lisboa
+type: fixed
diff --git a/changelogs/unreleased/refresh-archived-badge-style.yml b/changelogs/unreleased/refresh-archived-badge-style.yml
new file mode 100644
index 00000000000..1c252e84008
--- /dev/null
+++ b/changelogs/unreleased/refresh-archived-badge-style.yml
@@ -0,0 +1,5 @@
+---
+title: Use new badge style for 'archived' project badge
+merge_request: 38013
+author:
+type: changed
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 16228b78b34..803a6f0c59d 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -283,6 +283,7 @@ Settings.sentry['clientside_dsn'] ||= nil
# Pages
#
Settings['pages'] ||= Settingslogic.new({})
+Settings['pages'] = ::Gitlab::Pages::Settings.new(Settings.pages) # For path access detection https://gitlab.com/gitlab-org/gitlab/-/issues/230702
Settings.pages['enabled'] = false if Settings.pages['enabled'].nil?
Settings.pages['access_control'] = false if Settings.pages['access_control'].nil?
Settings.pages['path'] = Settings.absolute(Settings.pages['path'] || File.join(Settings.shared['path'], "pages"))
diff --git a/lib/gitlab/pages.rb b/lib/gitlab/pages.rb
index c8cb8b6e020..33e709360ad 100644
--- a/lib/gitlab/pages.rb
+++ b/lib/gitlab/pages.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Gitlab
- class Pages
+ module Pages
VERSION = File.read(Rails.root.join("GITLAB_PAGES_VERSION")).strip.freeze
INTERNAL_API_REQUEST_HEADER = 'Gitlab-Pages-Api-Request'.freeze
MAX_SIZE = 1.terabyte
diff --git a/lib/gitlab/pages/settings.rb b/lib/gitlab/pages/settings.rb
new file mode 100644
index 00000000000..e3dbeee7b13
--- /dev/null
+++ b/lib/gitlab/pages/settings.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Pages
+ class Settings < ::SimpleDelegator
+ DiskAccessDenied = Class.new(StandardError)
+
+ def path
+ if ::Gitlab::Runtime.web_server? && ENV['GITLAB_PAGES_DENY_DISK_ACCESS'] == '1'
+ begin
+ raise DiskAccessDenied
+ rescue DiskAccessDenied => ex
+ ::Gitlab::ErrorTracking.track_exception(ex)
+ end
+ end
+
+ super
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ab5b01e0dd0..215daddf327 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -19033,6 +19033,9 @@ msgstr ""
msgid "PrometheusAlerts|Operator"
msgstr ""
+msgid "PrometheusAlerts|Runbook"
+msgstr ""
+
msgid "PrometheusAlerts|Select query"
msgstr ""
@@ -21153,9 +21156,6 @@ msgstr ""
msgid "SecurityReports|No vulnerabilities found"
msgstr ""
-msgid "SecurityReports|No vulnerabilities found for this group"
-msgstr ""
-
msgid "SecurityReports|No vulnerabilities found for this pipeline"
msgstr ""
@@ -21258,9 +21258,6 @@ msgstr ""
msgid "SecurityReports|Undo dismiss"
msgstr ""
-msgid "SecurityReports|While it's rare to have no vulnerabilities for your group, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly."
-msgstr ""
-
msgid "SecurityReports|While it's rare to have no vulnerabilities for your pipeline, it can happen. In any event, we ask that you double check your settings to make sure all security scanning jobs have passed successfully."
msgstr ""
diff --git a/qa/qa/resource/members.rb b/qa/qa/resource/members.rb
index 4ebed37ca23..52928afa7db 100644
--- a/qa/qa/resource/members.rb
+++ b/qa/qa/resource/members.rb
@@ -8,9 +8,12 @@ module QA
#
module Members
def add_member(user, access_level = AccessLevel::DEVELOPER)
- QA::Runtime::Logger.debug(%Q[Adding user #{user.username} to #{full_path} #{self.class.name}])
+ Support::Retrier.retry_until do
+ QA::Runtime::Logger.debug(%Q[Adding user #{user.username} to #{full_path} #{self.class.name}])
- post Runtime::API::Request.new(api_client, api_members_path).url, { user_id: user.id, access_level: access_level }
+ response = post Runtime::API::Request.new(api_client, api_members_path).url, { user_id: user.id, access_level: access_level }
+ response.code == QA::Support::Api::HTTP_STATUS_CREATED
+ end
end
def remove_member(user)
diff --git a/qa/qa/support/wait_for_requests.rb b/qa/qa/support/wait_for_requests.rb
index d2451b6c6e5..6d263b73208 100644
--- a/qa/qa/support/wait_for_requests.rb
+++ b/qa/qa/support/wait_for_requests.rb
@@ -27,7 +27,10 @@ module QA
# The number of selectors should be able to be reduced after
# migration to the new spinner is complete.
# https://gitlab.com/groups/gitlab-org/-/epics/956
- Capybara.page.has_no_css?('.gl-spinner, .fa-spinner, .spinner', wait: wait)
+ # retry_on_exception added here due to `StaleElementReferenceError`. See: https://gitlab.com/gitlab-org/gitlab/-/issues/232485
+ Support::Retrier.retry_on_exception do
+ Capybara.page.has_no_css?('.gl-spinner, .fa-spinner, .spinner', wait: wait)
+ end
end
end
end
diff --git a/scripts/trigger-build b/scripts/trigger-build
index cfaccaf4fe3..7fc550d86ee 100755
--- a/scripts/trigger-build
+++ b/scripts/trigger-build
@@ -274,6 +274,8 @@ module Trigger
def create_remote_branch!
Gitlab.create_branch(downstream_project_path, ref, 'master')
puts "=> Remote branch '#{ref}' created"
+ rescue Gitlab::Error::BadRequest
+ puts "=> Remote branch '#{ref}' already exists!"
end
def cancel_latest_pipeline!
@@ -292,8 +294,6 @@ module Trigger
# Cancel the pipeline
Gitlab.cancel_pipeline(downstream_project_path, pipeline_id)
- rescue Gitlab::Error::BadRequest
- puts "=> Remote branch '#{ref}' already exists!"
end
def display_success_message
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index 9b2373bf28b..0dff4c28270 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -450,5 +450,19 @@ RSpec.describe 'Runners' do
expect(all(:link, href: group_runner_path(group, runner)).length).to eq(1)
end
end
+
+ context 'filtered search' do
+ it 'allows user to search by status and type', :js do
+ visit group_settings_ci_cd_path(group)
+
+ find('.filtered-search').click
+
+ page.within('#js-dropdown-hint') do
+ expect(page).to have_content('Status')
+ expect(page).to have_content('Type')
+ expect(page).not_to have_content('Tag')
+ end
+ end
+ end
end
end
diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js
index a8416216a94..1909c4c7cdb 100644
--- a/spec/frontend/monitoring/components/alert_widget_form_spec.js
+++ b/spec/frontend/monitoring/components/alert_widget_form_spec.js
@@ -29,7 +29,7 @@ describe('AlertWidgetForm', () => {
configuredAlert: metricId,
};
- function createComponent(props = {}) {
+ function createComponent(props = {}, featureFlags = {}) {
const propsData = {
...defaultProps,
...props,
@@ -37,6 +37,9 @@ describe('AlertWidgetForm', () => {
wrapper = shallowMount(AlertWidgetForm, {
propsData,
+ provide: {
+ glFeatures: featureFlags,
+ },
stubs: {
GlModal: ModalStub,
},
@@ -46,6 +49,7 @@ describe('AlertWidgetForm', () => {
const modal = () => wrapper.find(ModalStub);
const modalTitle = () => modal().attributes('title');
const submitButton = () => modal().find(GlLink);
+ const alertRunbookField = () => wrapper.find('[data-testid="alertRunbookField"]');
const submitButtonTrackingOpts = () =>
JSON.parse(submitButton().attributes('data-tracking-options'));
const e = {
@@ -217,4 +221,18 @@ describe('AlertWidgetForm', () => {
});
});
});
+
+ describe('alert runbooks feature flag', () => {
+ it('hides the runbook field when the flag is disabled', () => {
+ createComponent(undefined, { alertRunbooks: false });
+
+ expect(alertRunbookField().exists()).toBe(false);
+ });
+
+ it('shows the runbook field when the flag is enabled', () => {
+ createComponent(undefined, { alertRunbooks: true });
+
+ expect(alertRunbookField().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb b/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb
index 1a56a91c471..fa4f8a20984 100644
--- a/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/expression/lexeme/pattern_spec.rb
@@ -70,7 +70,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Expression::Lexeme::Pattern do
.to eq Gitlab::UntrustedRegexp.new('pattern')
end
- it 'is a eager scanner for regexp boundaries' do
+ it 'is an eager scanner for regexp boundaries' do
scanner = StringScanner.new('/some .* / pattern/')
token = described_class.scan(scanner)
diff --git a/spec/lib/gitlab/pages/settings_spec.rb b/spec/lib/gitlab/pages/settings_spec.rb
new file mode 100644
index 00000000000..7d4db073d73
--- /dev/null
+++ b/spec/lib/gitlab/pages/settings_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Pages::Settings do
+ describe '#path' do
+ subject { described_class.new(settings).path }
+
+ let(:settings) { double(path: 'the path') }
+
+ it { is_expected.to eq('the path') }
+
+ it 'does not track calls' do
+ expect(::Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ subject
+ end
+
+ context 'when running under a web server' do
+ before do
+ allow(::Gitlab::Runtime).to receive(:web_server?).and_return(true)
+ end
+
+ it { is_expected.to eq('the path') }
+
+ it 'does not track calls' do
+ expect(::Gitlab::ErrorTracking).not_to receive(:track_exception)
+
+ subject
+ end
+
+ context 'with the env var' do
+ before do
+ stub_env('GITLAB_PAGES_DENY_DISK_ACCESS', '1')
+ end
+
+ it { is_expected.to eq('the path') }
+
+ it 'tracks a DiskAccessDenied exception' do
+ expect(::Gitlab::ErrorTracking).to receive(:track_exception)
+ .with(instance_of(described_class::DiskAccessDenied)).and_call_original
+
+ subject
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb
index 3d065deefdf..3014ccbd7ba 100644
--- a/spec/services/projects/container_repository/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb
@@ -3,21 +3,15 @@
require 'spec_helper'
RSpec.describe Projects::ContainerRepository::DeleteTagsService do
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, :private) }
- let_it_be(:repository) { create(:container_repository, :root, project: project) }
+ include_context 'container repository delete tags service shared context'
- let(:params) { { tags: tags } }
let(:service) { described_class.new(project, user, params) }
- before do
- stub_container_registry_config(enabled: true,
- api_url: 'http://registry.gitlab',
- host_port: 'registry.gitlab')
-
- stub_container_registry_tags(
- repository: repository.path,
- tags: %w(latest A Ba Bb C D E))
+ let_it_be(:available_service_classes) do
+ [
+ ::Projects::ContainerRepository::Gitlab::DeleteTagsService,
+ ::Projects::ContainerRepository::ThirdParty::DeleteTagsService
+ ]
end
RSpec.shared_examples 'logging a success response' do
@@ -45,8 +39,54 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
end
end
+ RSpec.shared_examples 'calling the correct delete tags service' do |expected_service_class|
+ let(:service_response) { { status: :success, deleted: tags } }
+ let(:excluded_service_class) { available_service_classes.excluding(expected_service_class).first }
+
+ before do
+ service_double = double
+ expect(expected_service_class).to receive(:new).with(repository, tags).and_return(service_double)
+ expect(excluded_service_class).not_to receive(:new)
+ expect(service_double).to receive(:execute).and_return(service_response)
+ end
+
+ it { is_expected.to include(status: :success) }
+
+ it_behaves_like 'logging a success response'
+
+ context 'with an error service response' do
+ let(:service_response) { { status: :error, message: 'could not delete tags' } }
+
+ it { is_expected.to include(status: :error) }
+
+ it_behaves_like 'logging an error response'
+ end
+ end
+
+ RSpec.shared_examples 'handling invalid params' do
+ context 'with invalid params' do
+ before do
+ expect(::Projects::ContainerRepository::Gitlab::DeleteTagsService).not_to receive(:new)
+ expect(::Projects::ContainerRepository::ThirdParty::DeleteTagsService).not_to receive(:new)
+ expect_any_instance_of(ContainerRegistry::Client).not_to receive(:delete_repository_tag_by_name)
+ end
+
+ context 'when no params are specified' do
+ let_it_be(:params) { {} }
+
+ it { is_expected.to include(status: :error) }
+ end
+
+ context 'with empty tags' do
+ let_it_be(:tags) { [] }
+
+ it { is_expected.to include(status: :error) }
+ end
+ end
+ end
+
describe '#execute' do
- let(:tags) { %w[A] }
+ let(:tags) { %w[A Ba] }
subject { service.execute(repository) }
@@ -61,247 +101,58 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
context 'when the registry supports fast delete' do
context 'and the feature is enabled' do
- let_it_be(:project) { create(:project, :private) }
- let_it_be(:repository) { create(:container_repository, :root, project: project) }
-
before do
allow(repository.client).to receive(:supports_tag_delete?).and_return(true)
end
- context 'with tags to delete' do
- let_it_be(:tags) { %w[A Ba] }
+ it_behaves_like 'calling the correct delete tags service', ::Projects::ContainerRepository::Gitlab::DeleteTagsService
- it 'deletes the tags by name' do
- stub_delete_reference_request('A')
- stub_delete_reference_request('Ba')
+ it_behaves_like 'handling invalid params'
- expect_delete_tag_by_name('A')
- expect_delete_tag_by_name('Ba')
-
- is_expected.to include(status: :success)
+ context 'with the real service' do
+ before do
+ stub_delete_reference_requests(tags)
+ expect_delete_tag_by_names(tags)
end
- it 'succeeds when tag delete returns 404' do
- stub_delete_reference_request('A')
- stub_delete_reference_request('Ba', 404)
+ it { is_expected.to include(status: :success) }
- is_expected.to include(status: :success)
- end
-
- it_behaves_like 'logging a success response' do
- before do
- stub_delete_reference_request('A')
- stub_delete_reference_request('Ba')
- end
- end
-
- context 'with failures' do
- context 'when the delete request fails' do
- before do
- stub_delete_reference_request('A', 500)
- stub_delete_reference_request('Ba', 500)
- end
-
- it { is_expected.to include(status: :error) }
-
- it_behaves_like 'logging an error response'
- end
- end
- end
-
- context 'when no params are specified' do
- let_it_be(:params) { {} }
-
- it 'does not remove anything' do
- expect_any_instance_of(ContainerRegistry::Client).not_to receive(:delete_repository_tag_by_name)
-
- is_expected.to include(status: :error)
- end
- end
-
- context 'with empty tags' do
- let_it_be(:tags) { [] }
-
- it 'does not remove anything' do
- expect_any_instance_of(ContainerRegistry::Client).not_to receive(:delete_repository_tag_by_name)
-
- is_expected.to include(status: :error)
- end
+ it_behaves_like 'logging a success response'
end
end
context 'and the feature is disabled' do
- let_it_be(:tags) { %w[A Ba] }
-
before do
stub_feature_flags(container_registry_fast_tag_delete: false)
- stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
- stub_put_manifest_request('A')
- stub_put_manifest_request('Ba')
end
- it 'fallbacks to slow delete' do
- expect(service).not_to receive(:fast_delete)
- expect(service).to receive(:slow_delete).with(repository, tags).and_call_original
+ it_behaves_like 'calling the correct delete tags service', ::Projects::ContainerRepository::ThirdParty::DeleteTagsService
- expect_delete_tag_by_digest('sha256:dummy')
+ it_behaves_like 'handling invalid params'
- subject
- end
-
- it_behaves_like 'logging a success response' do
+ context 'with the real service' do
before do
- allow(service).to receive(:slow_delete).and_call_original
+ stub_upload('sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
+ tags.each { |tag| stub_put_manifest_request(tag) }
expect_delete_tag_by_digest('sha256:dummy')
end
+
+ it { is_expected.to include(status: :success) }
+
+ it_behaves_like 'logging a success response'
end
end
end
context 'when the registry does not support fast delete' do
- let_it_be(:project) { create(:project, :private) }
- let_it_be(:repository) { create(:container_repository, :root, project: project) }
-
before do
- stub_tag_digest('latest', 'sha256:configA')
- stub_tag_digest('A', 'sha256:configA')
- stub_tag_digest('Ba', 'sha256:configB')
-
allow(repository.client).to receive(:supports_tag_delete?).and_return(false)
end
- context 'when no params are specified' do
- let_it_be(:params) { {} }
+ it_behaves_like 'calling the correct delete tags service', ::Projects::ContainerRepository::ThirdParty::DeleteTagsService
- it 'does not remove anything' do
- expect_any_instance_of(ContainerRegistry::Client).not_to receive(:delete_repository_tag_by_digest)
-
- is_expected.to include(status: :error)
- end
- end
-
- context 'with empty tags' do
- let_it_be(:tags) { [] }
-
- it 'does not remove anything' do
- expect_any_instance_of(ContainerRegistry::Client).not_to receive(:delete_repository_tag_by_digest)
-
- is_expected.to include(status: :error)
- end
- end
-
- context 'with tags to delete' do
- let_it_be(:tags) { %w[A Ba] }
-
- it 'deletes the tags using a dummy image' do
- stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
-
- stub_put_manifest_request('A')
- stub_put_manifest_request('Ba')
-
- expect_delete_tag_by_digest('sha256:dummy')
-
- is_expected.to include(status: :success)
- end
-
- it 'succeeds when tag delete returns 404' do
- stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
-
- stub_put_manifest_request('A')
- stub_put_manifest_request('Ba')
-
- stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/manifests/sha256:dummy")
- .to_return(status: 404, body: "", headers: {})
-
- is_expected.to include(status: :success)
- end
-
- it_behaves_like 'logging a success response' do
- before do
- stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
- stub_put_manifest_request('A')
- stub_put_manifest_request('Ba')
- expect_delete_tag_by_digest('sha256:dummy')
- end
- end
-
- context 'with failures' do
- context 'when the dummy manifest generation fails' do
- before do
- stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3', success: false)
- end
-
- it { is_expected.to include(status: :error) }
-
- it_behaves_like 'logging an error response', message: 'could not generate manifest'
- end
-
- context 'when updating the tags fails' do
- before do
- stub_upload("{\n \"config\": {\n }\n}", 'sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
-
- stub_put_manifest_request('A', 500)
- stub_put_manifest_request('Ba', 500)
-
- stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/manifests/sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3")
- .to_return(status: 200, body: "", headers: {})
- end
-
- it { is_expected.to include(status: :error) }
- it_behaves_like 'logging an error response'
- end
- end
- end
+ it_behaves_like 'handling invalid params'
end
end
end
-
- private
-
- def stub_delete_reference_request(tag, status = 200)
- stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/tags/reference/#{tag}")
- .to_return(status: status, body: '')
- end
-
- def stub_put_manifest_request(tag, status = 200, headers = { 'docker-content-digest' => 'sha256:dummy' })
- stub_request(:put, "http://registry.gitlab/v2/#{repository.path}/manifests/#{tag}")
- .to_return(status: status, body: '', headers: headers)
- end
-
- def stub_tag_digest(tag, digest)
- stub_request(:head, "http://registry.gitlab/v2/#{repository.path}/manifests/#{tag}")
- .to_return(status: 200, body: "", headers: { 'docker-content-digest' => digest })
- end
-
- def stub_digest_config(digest, created_at)
- allow_any_instance_of(ContainerRegistry::Client)
- .to receive(:blob)
- .with(repository.path, digest, nil) do
- { 'created' => created_at.to_datetime.rfc3339 }.to_json if created_at
- end
- end
-
- def stub_upload(content, digest, success: true)
- expect_any_instance_of(ContainerRegistry::Client)
- .to receive(:upload_blob)
- .with(repository.path, content, digest) { double(success?: success ) }
- end
-
- def expect_delete_tag_by_digest(digest)
- expect_any_instance_of(ContainerRegistry::Client)
- .to receive(:delete_repository_tag_by_digest)
- .with(repository.path, digest) { true }
-
- expect_any_instance_of(ContainerRegistry::Client)
- .not_to receive(:delete_repository_tag_by_name)
- end
-
- def expect_delete_tag_by_name(name)
- expect_any_instance_of(ContainerRegistry::Client)
- .to receive(:delete_repository_tag_by_name)
- .with(repository.path, name) { true }
-
- expect_any_instance_of(ContainerRegistry::Client)
- .not_to receive(:delete_repository_tag_by_digest)
- end
end
diff --git a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
new file mode 100644
index 00000000000..68c232e5d83
--- /dev/null
+++ b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
+ include_context 'container repository delete tags service shared context'
+
+ let(:service) { described_class.new(repository, tags) }
+
+ describe '#execute' do
+ let(:tags) { %w[A Ba] }
+
+ subject { service.execute }
+
+ context 'with tags to delete' do
+ it 'deletes the tags by name' do
+ stub_delete_reference_requests(tags)
+ expect_delete_tag_by_names(tags)
+
+ is_expected.to eq(status: :success, deleted: tags)
+ end
+
+ it 'succeeds when tag delete returns 404' do
+ stub_delete_reference_requests('A' => 200, 'Ba' => 404)
+
+ is_expected.to eq(status: :success, deleted: tags)
+ end
+
+ it 'succeeds when a tag delete returns 500' do
+ stub_delete_reference_requests('A' => 200, 'Ba' => 500)
+
+ is_expected.to eq(status: :success, deleted: ['A'])
+ end
+
+ context 'with failures' do
+ context 'when the delete request fails' do
+ before do
+ stub_delete_reference_requests('A' => 500, 'Ba' => 500)
+ end
+
+ it { is_expected.to eq(status: :error, message: 'could not delete tags') }
+ end
+ end
+ end
+
+ context 'with empty tags' do
+ let_it_be(:tags) { [] }
+
+ it 'does not remove anything' do
+ expect_any_instance_of(ContainerRegistry::Client).not_to receive(:delete_repository_tag_by_name)
+
+ is_expected.to eq(status: :success, deleted: [])
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
new file mode 100644
index 00000000000..7fc963949eb
--- /dev/null
+++ b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::ContainerRepository::ThirdParty::DeleteTagsService do
+ include_context 'container repository delete tags service shared context'
+
+ let(:service) { described_class.new(repository, tags) }
+
+ describe '#execute' do
+ let(:tags) { %w[A Ba] }
+
+ subject { service.execute }
+
+ context 'with tags to delete' do
+ it 'deletes the tags by name' do
+ stub_upload('sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
+
+ tags.each { |tag| stub_put_manifest_request(tag) }
+
+ expect_delete_tag_by_digest('sha256:dummy')
+
+ is_expected.to eq(status: :success, deleted: tags)
+ end
+
+ it 'succeeds when tag delete returns 404' do
+ stub_upload('sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
+
+ stub_put_manifest_request('A')
+ stub_put_manifest_request('Ba')
+
+ stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/manifests/sha256:dummy")
+ .to_return(status: 404, body: '', headers: {})
+
+ is_expected.to eq(status: :success, deleted: tags)
+ end
+
+ context 'with failures' do
+ context 'when the dummy manifest generation fails' do
+ before do
+ stub_upload('sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3', success: false)
+ end
+
+ it { is_expected.to eq(status: :error, message: 'could not generate manifest') }
+ end
+
+ context 'when updating tags fails' do
+ before do
+ stub_upload('sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3')
+
+ stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/manifests/sha256:4435000728ee66e6a80e55637fc22725c256b61de344a2ecdeaac6bdb36e8bc3")
+ .to_return(status: 200, body: '', headers: {})
+ end
+
+ context 'all tag updates fail' do
+ before do
+ stub_put_manifest_request('A', 500, {})
+ stub_put_manifest_request('Ba', 500, {})
+ end
+
+ it { is_expected.to eq(status: :error, message: 'could not delete tags') }
+ end
+
+ context 'a single tag update fails' do
+ before do
+ stub_put_manifest_request('A')
+ stub_put_manifest_request('Ba', 500, {})
+
+ stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/manifests/sha256:dummy")
+ .to_return(status: 404, body: '', headers: {})
+ end
+
+ it { is_expected.to eq(status: :success, deleted: ['A']) }
+ end
+ end
+ end
+ end
+
+ context 'with empty tags' do
+ let_it_be(:tags) { [] }
+
+ it 'does not remove anything' do
+ expect_any_instance_of(ContainerRegistry::Client).not_to receive(:delete_repository_tag_by_name)
+
+ is_expected.to eq(status: :success, deleted: [])
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb b/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb
new file mode 100644
index 00000000000..bcc98cf6416
--- /dev/null
+++ b/spec/support/shared_contexts/services/projects/container_repository/delete_tags_service_shared_context.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'container repository delete tags service shared context' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project, reload: true) { create(:project, :private) }
+ let_it_be(:repository) { create(:container_repository, :root, project: project) }
+
+ let(:params) { { tags: tags } }
+
+ before do
+ stub_container_registry_config(enabled: true,
+ api_url: 'http://registry.gitlab',
+ host_port: 'registry.gitlab')
+
+ stub_container_registry_tags(
+ repository: repository.path,
+ tags: %w(latest A Ba Bb C D E))
+ end
+
+ def stub_delete_reference_request(tag, status = 200)
+ stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/tags/reference/#{tag}")
+ .to_return(status: status, body: '')
+ end
+
+ def stub_delete_reference_requests(tags)
+ tags = Hash[Array.wrap(tags).map { |tag| [tag, 200] }] unless tags.is_a?(Hash)
+
+ tags.each do |tag, status|
+ stub_request(:delete, "http://registry.gitlab/v2/#{repository.path}/tags/reference/#{tag}")
+ .to_return(status: status, body: '')
+ end
+ end
+
+ def stub_put_manifest_request(tag, status = 200, headers = { 'docker-content-digest' => 'sha256:dummy' })
+ stub_request(:put, "http://registry.gitlab/v2/#{repository.path}/manifests/#{tag}")
+ .to_return(status: status, body: '', headers: headers)
+ end
+
+ def stub_tag_digest(tag, digest)
+ stub_request(:head, "http://registry.gitlab/v2/#{repository.path}/manifests/#{tag}")
+ .to_return(status: 200, body: '', headers: { 'docker-content-digest' => digest })
+ end
+
+ def stub_digest_config(digest, created_at)
+ allow_any_instance_of(ContainerRegistry::Client)
+ .to receive(:blob)
+ .with(repository.path, digest, nil) do
+ { 'created' => created_at.to_datetime.rfc3339 }.to_json if created_at
+ end
+ end
+
+ def stub_upload(digest, success: true)
+ content = "{\n \"config\": {\n }\n}"
+ expect_any_instance_of(ContainerRegistry::Client)
+ .to receive(:upload_blob)
+ .with(repository.path, content, digest) { double(success?: success ) }
+ end
+
+ def expect_delete_tag_by_digest(digest)
+ expect_any_instance_of(ContainerRegistry::Client)
+ .to receive(:delete_repository_tag_by_digest)
+ .with(repository.path, digest) { true }
+
+ expect_any_instance_of(ContainerRegistry::Client)
+ .not_to receive(:delete_repository_tag_by_name)
+ end
+
+ def expect_delete_tag_by_names(names)
+ Array.wrap(names).each do |name|
+ expect_any_instance_of(ContainerRegistry::Client)
+ .to receive(:delete_repository_tag_by_name)
+ .with(repository.path, name) { true }
+
+ expect_any_instance_of(ContainerRegistry::Client)
+ .not_to receive(:delete_repository_tag_by_digest)
+ end
+ end
+end