diff --git a/Gemfile b/Gemfile index 661862084b9..dd1054080ea 100644 --- a/Gemfile +++ b/Gemfile @@ -25,8 +25,7 @@ gem 'marginalia', '~> 1.10.0' # Authentication libraries gem 'devise', '~> 4.7.2' -# TODO: verify ARM compile issue on 3.1.13+ version (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18828) -gem 'bcrypt', '3.1.12' +gem 'bcrypt', '~> 3.1', '>= 3.1.14' gem 'doorkeeper', '~> 5.5.0.rc2' gem 'doorkeeper-openid_connect', '~> 1.7.5' gem 'omniauth', '~> 1.8' diff --git a/Gemfile.lock b/Gemfile.lock index 8a35c0d64d5..9a5c0bc10d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -128,7 +128,7 @@ GEM babosa (1.0.2) base32 (0.3.2) batch-loader (2.0.1) - bcrypt (3.1.12) + bcrypt (3.1.16) bcrypt_pbkdf (1.0.0) benchmark-ips (2.3.0) benchmark-memory (0.1.2) @@ -1346,7 +1346,7 @@ DEPENDENCIES babosa (~> 1.0.2) base32 (~> 0.3.0) batch-loader (~> 2.0.1) - bcrypt (= 3.1.12) + bcrypt (~> 3.1, >= 3.1.14) bcrypt_pbkdf (~> 1.0) benchmark-ips (~> 2.3.0) benchmark-memory (~> 0.1) diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index da2890f91fc..93fbbf07ae2 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -2,30 +2,8 @@ import $ from 'jquery'; import { escape } from 'lodash'; import { __ } from '~/locale'; import Api from './api'; -import axios from './lib/utils/axios_utils'; -import { normalizeHeaders } from './lib/utils/common_utils'; import { loadCSSFile } from './lib/utils/css_utils'; - -const fetchGroups = (params) => { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then((res) => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; - - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); -}; +import { select2AxiosTransport } from './lib/utils/select2_utils'; const groupsSelect = () => { loadCSSFile(gon.select2_css_path) @@ -51,9 +29,7 @@ const groupsSelect = () => { url: Api.buildUrl(groupsPath), dataType: 'json', quietMillis: 250, - transport(params) { - fetchGroups(params); - }, + transport: select2AxiosTransport, data(search, page) { return { search, @@ -63,8 +39,6 @@ const groupsSelect = () => { }; }, results(data, page) { - if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; const more = data.pagination ? data.pagination.more : false; const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 1b06dffbae7..153123a005f 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -5,6 +5,7 @@ import Autosave from './autosave'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { loadCSSFile } from './lib/utils/css_utils'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; +import { select2AxiosTransport } from './lib/utils/select2_utils'; import { queryToObject, objectToQuery } from './lib/utils/url_utility'; import UsersSelect from './users_select'; import ZenMode from './zen_mode'; @@ -199,15 +200,16 @@ export default class IssuableForm { search, }; }, - results(data) { + results({ results }) { return { // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map((name) => ({ + results: results[Object.keys(results)[0]].map((name) => ({ id: name, text: name, })), }; }, + transport: select2AxiosTransport, }, initSelection(el, callback) { const val = el.val(); diff --git a/app/assets/javascripts/lib/utils/select2_utils.js b/app/assets/javascripts/lib/utils/select2_utils.js new file mode 100644 index 00000000000..03c0e608b79 --- /dev/null +++ b/app/assets/javascripts/lib/utils/select2_utils.js @@ -0,0 +1,25 @@ +import axios from './axios_utils'; +import { normalizeHeaders, parseIntPagination } from './common_utils'; + +// This is used in the select2 config to replace jQuery.ajax with axios +export const select2AxiosTransport = (params) => { + axios({ + method: params.type?.toLowerCase() || 'get', + url: params.url, + params: params.data, + }) + .then((res) => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const pagination = parseIntPagination(headers); + const more = pagination.nextPage > pagination.page; + + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); +}; diff --git a/app/models/environment.rb b/app/models/environment.rb index 3ac7e63bae3..a6c65d1fd79 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -96,6 +96,22 @@ class Environment < ApplicationRecord end scope :for_id, -> (id) { where(id: id) } + scope :stopped_review_apps, -> (before, limit) do + stopped + .in_review_folder + .where("created_at < ?", before) + .order("created_at ASC") + .limit(limit) + end + + scope :scheduled_for_deletion, -> do + where.not(auto_delete_at: nil) + end + + scope :not_scheduled_for_deletion, -> do + where(auto_delete_at: nil) + end + enum tier: { production: 0, staging: 1, @@ -147,6 +163,10 @@ class Environment < ApplicationRecord self.state_machine.states.map(&:name) end + def self.schedule_to_delete(at_time = 1.week.from_now) + update_all(auto_delete_at: at_time) + end + class << self ## # This method returns stop actions (jobs) for multiple environments within one diff --git a/app/services/environments/schedule_to_delete_review_apps_service.rb b/app/services/environments/schedule_to_delete_review_apps_service.rb new file mode 100644 index 00000000000..b3b86689748 --- /dev/null +++ b/app/services/environments/schedule_to_delete_review_apps_service.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Environments + class ScheduleToDeleteReviewAppsService < ::BaseService + include ::Gitlab::ExclusiveLeaseHelpers + + EXCLUSIVE_LOCK_KEY_BASE = 'environments:delete_review_apps:lock' + LOCK_TIMEOUT = 2.minutes + + def execute + if validation_error = validate + return validation_error + end + + mark_deletable_environments + end + + private + + def key + "#{EXCLUSIVE_LOCK_KEY_BASE}:#{project.id}" + end + + def dry_run? + return true if params[:dry_run].nil? + + params[:dry_run] + end + + def validate + return if can?(current_user, :destroy_environment, project) + + Result.new(error_message: "You do not have permission to destroy environments in this project", status: :unauthorized) + end + + def mark_deletable_environments + in_lock(key, ttl: LOCK_TIMEOUT, retries: 1) do + unsafe_mark_deletable_environments + end + + rescue FailedToObtainLockError + Result.new(error_message: "Another process is already processing a delete request. Please retry later.", status: :conflict) + end + + def unsafe_mark_deletable_environments + result = Result.new + environments = project.environments + .not_scheduled_for_deletion + .stopped_review_apps(params[:before], params[:limit]) + + # Check if the actor has write permission to a potentially-protected environment. + deletable, failed = *environments.partition { |env| current_user.can?(:destroy_environment, env) } + + if deletable.any? && failed.empty? + mark_for_deletion(deletable) unless dry_run? + result.set_status(:ok) + result.set_scheduled_entries(deletable) + else + result.set_status( + :bad_request, + error_message: "Failed to authorize deletions for some or all of the environments. Ask someone with more permissions to delete the environments." + ) + + result.set_unprocessable_entries(failed) + end + + result + end + + def mark_for_deletion(deletable_environments) + Environment.for_id(deletable_environments).schedule_to_delete + end + + class Result + attr_accessor :scheduled_entries, :unprocessable_entries, :error_message, :status + + def initialize(scheduled_entries: [], unprocessable_entries: [], error_message: nil, status: nil) + self.scheduled_entries = scheduled_entries + self.unprocessable_entries = unprocessable_entries + self.error_message = error_message + self.status = status + end + + def success? + status == :ok + end + + def set_status(status, error_message: nil) + self.status = status + self.error_message = error_message + end + + def set_scheduled_entries(entries) + self.scheduled_entries = entries + end + + def set_unprocessable_entries(entries) + self.unprocessable_entries = entries + end + end + end +end diff --git a/changelogs/unreleased/stale-environment-cleanup.yml b/changelogs/unreleased/stale-environment-cleanup.yml new file mode 100644 index 00000000000..db4bc61f080 --- /dev/null +++ b/changelogs/unreleased/stale-environment-cleanup.yml @@ -0,0 +1,5 @@ +--- +title: Add API endpoint for deleting stale review envs +merge_request: 52224 +author: +type: added diff --git a/changelogs/unreleased/update-ci-template-default-branch-1.yml b/changelogs/unreleased/update-ci-template-default-branch-1.yml new file mode 100644 index 00000000000..a1236e353d1 --- /dev/null +++ b/changelogs/unreleased/update-ci-template-default-branch-1.yml @@ -0,0 +1,5 @@ +--- +title: Update Pages template examples to default branch +merge_request: 56298 +author: +type: other diff --git a/doc/user/admin_area/settings/sign_up_restrictions.md b/doc/user/admin_area/settings/sign_up_restrictions.md index aacea397aaa..0078db286a8 100644 --- a/doc/user/admin_area/settings/sign_up_restrictions.md +++ b/doc/user/admin_area/settings/sign_up_restrictions.md @@ -62,25 +62,6 @@ their account. If an administrator [increases](#set-the-user-cap-number) or [removes](#remove-the-user-cap) the user cap, the users in pending approval state are automatically approved in a background job. -### Enable or disable User cap **(FREE SELF)** - -User cap is under development but ready for production use. -It is deployed behind a feature flag that is **enabled by default**. -[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) -can opt to disable it. - -To disable it: - -```ruby -Feature.disable(:admin_new_user_signups_cap) -``` - -To enable it: - -```ruby -Feature.enable(:admin_new_user_signups_cap) -``` - ### Set the user cap number 1. Go to **Admin Area > Settings > General**. diff --git a/lib/api/environments.rb b/lib/api/environments.rb index 5dd2fa22690..3e1e430c2f9 100644 --- a/lib/api/environments.rb +++ b/lib/api/environments.rb @@ -75,6 +75,33 @@ module API end end + desc "Delete multiple stopped review apps" do + detail "Remove multiple stopped review environments older than a specific age" + success Entities::Environment + end + params do + optional :before, type: Time, desc: "The timestamp before which environments can be deleted. Defaults to 30 days ago.", default: -> { 30.days.ago } + optional :limit, type: Integer, desc: "Maximum number of environments to delete. Defaults to 100.", default: 100, values: 1..1000 + optional :dry_run, type: Boolean, desc: "If set, perform a dry run where no actual deletions will be performed. Defaults to true.", default: true + end + delete ":id/environments/review_apps" do + authorize! :read_environment, user_project + + result = ::Environments::ScheduleToDeleteReviewAppsService.new(user_project, current_user, params).execute + + response = { + scheduled_entries: Entities::Environment.represent(result.scheduled_entries), + unprocessable_entries: Entities::Environment.represent(result.unprocessable_entries) + } + + if result.success? + status result.status + present response, current_user: current_user + else + render_api_error!(response.merge!(message: result.error_message), result.status) + end + end + desc 'Deletes an existing environment' do detail 'This feature was introduced in GitLab 8.11.' success Entities::Environment diff --git a/lib/gitlab/ci/templates/Pages/Brunch.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Brunch.gitlab-ci.yml index d2dd3fbfb75..90cd8472916 100644 --- a/lib/gitlab/ci/templates/Pages/Brunch.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Brunch.gitlab-ci.yml @@ -11,5 +11,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Doxygen.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Doxygen.gitlab-ci.yml index ba422c08614..7435afef572 100644 --- a/lib/gitlab/ci/templates/Pages/Doxygen.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Doxygen.gitlab-ci.yml @@ -9,5 +9,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Gatsby.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Gatsby.gitlab-ci.yml index 3a6eac63892..708c5063cc6 100644 --- a/lib/gitlab/ci/templates/Pages/Gatsby.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Gatsby.gitlab-ci.yml @@ -13,5 +13,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/HTML.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/HTML.gitlab-ci.yml index 92f25280c6e..694446dd6c9 100644 --- a/lib/gitlab/ci/templates/Pages/HTML.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/HTML.gitlab-ci.yml @@ -8,5 +8,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Harp.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Harp.gitlab-ci.yml index 0e206423fa5..a2fd6620909 100644 --- a/lib/gitlab/ci/templates/Pages/Harp.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Harp.gitlab-ci.yml @@ -11,5 +11,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Hexo.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Hexo.gitlab-ci.yml index d91a8d7421f..fd75e47e899 100644 --- a/lib/gitlab/ci/templates/Pages/Hexo.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Hexo.gitlab-ci.yml @@ -13,5 +13,5 @@ pages: paths: - node_modules key: project - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Hugo.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Hugo.gitlab-ci.yml index 975cb3b7698..a6a605e35f0 100644 --- a/lib/gitlab/ci/templates/Pages/Hugo.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Hugo.gitlab-ci.yml @@ -10,7 +10,8 @@ test: script: - hugo except: - - master + variables: + - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH pages: script: @@ -19,4 +20,5 @@ pages: paths: - public only: - - master + variables: + - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Hyde.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Hyde.gitlab-ci.yml index 7a441a2f70f..1be2f4bad76 100644 --- a/lib/gitlab/ci/templates/Pages/Hyde.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Hyde.gitlab-ci.yml @@ -11,7 +11,8 @@ test: - pip install hyde - hyde gen except: - - master + variables: + - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH pages: stage: deploy @@ -22,4 +23,5 @@ pages: paths: - public only: - - master + variables: + - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Jekyll.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Jekyll.gitlab-ci.yml index f2f92fe0704..01e063c50ad 100644 --- a/lib/gitlab/ci/templates/Pages/Jekyll.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Jekyll.gitlab-ci.yml @@ -18,7 +18,8 @@ test: paths: - test except: - - master + variables: + - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH pages: stage: deploy @@ -28,4 +29,5 @@ pages: paths: - public only: - - master + variables: + - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Jigsaw.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Jigsaw.gitlab-ci.yml index 2d26b86a328..e39aa8a2063 100644 --- a/lib/gitlab/ci/templates/Pages/Jigsaw.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Jigsaw.gitlab-ci.yml @@ -33,5 +33,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Lektor.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Lektor.gitlab-ci.yml index 93ab8e0be0d..13d3089f4fa 100644 --- a/lib/gitlab/ci/templates/Pages/Lektor.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Lektor.gitlab-ci.yml @@ -8,5 +8,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Metalsmith.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Metalsmith.gitlab-ci.yml index 6524405133a..e65cf3928f2 100644 --- a/lib/gitlab/ci/templates/Pages/Metalsmith.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Metalsmith.gitlab-ci.yml @@ -12,5 +12,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Middleman.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Middleman.gitlab-ci.yml index 462b4737c4e..377fd8c396e 100644 --- a/lib/gitlab/ci/templates/Pages/Middleman.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Middleman.gitlab-ci.yml @@ -12,7 +12,8 @@ test: - bundle install --path vendor - bundle exec middleman build except: - - master + variables: + - $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH pages: script: @@ -23,5 +24,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Nanoc.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Nanoc.gitlab-ci.yml index b512f8d77e9..89281b41b66 100644 --- a/lib/gitlab/ci/templates/Pages/Nanoc.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Nanoc.gitlab-ci.yml @@ -8,5 +8,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/Octopress.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/Octopress.gitlab-ci.yml index 4318aadcaa6..8fd4702b90d 100644 --- a/lib/gitlab/ci/templates/Pages/Octopress.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/Octopress.gitlab-ci.yml @@ -11,5 +11,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/lib/gitlab/ci/templates/Pages/SwaggerUI.gitlab-ci.yml b/lib/gitlab/ci/templates/Pages/SwaggerUI.gitlab-ci.yml index 8fd08ea7995..9fa8b07f7cb 100644 --- a/lib/gitlab/ci/templates/Pages/SwaggerUI.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Pages/SwaggerUI.gitlab-ci.yml @@ -25,5 +25,5 @@ pages: artifacts: paths: - public - only: - - master + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7c228bf6852..5b856762fc8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4666,6 +4666,9 @@ msgstr "" msgid "Be careful. Renaming a project's repository can have unintended side effects." msgstr "" +msgid "Before this can be merged, a Jira issue must be linked in the title or description" +msgstr "" + msgid "Begin with the selected commit" msgstr "" diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index 12710b6dcf3..072a5f1f402 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -32,6 +32,8 @@ FactoryBot.define do end trait :with_review_app do |environment| + sequence(:name) { |n| "review/#{n}" } + transient do ref { 'master' } end diff --git a/spec/frontend/lib/utils/select2_utils_spec.js b/spec/frontend/lib/utils/select2_utils_spec.js new file mode 100644 index 00000000000..6d601dd5ad1 --- /dev/null +++ b/spec/frontend/lib/utils/select2_utils_spec.js @@ -0,0 +1,100 @@ +import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; +import { setHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { select2AxiosTransport } from '~/lib/utils/select2_utils'; + +import 'select2/select2'; + +const TEST_URL = '/test/api/url'; +const TEST_SEARCH_DATA = { extraSearch: 'test' }; +const TEST_DATA = [{ id: 1 }]; +const TEST_SEARCH = 'FOO'; + +describe('lib/utils/select2_utils', () => { + let mock; + let resultsSpy; + + beforeEach(() => { + setHTMLFixture('
'); + + mock = new MockAdapter(axios); + + resultsSpy = jest.fn().mockReturnValue({ results: [] }); + }); + + afterEach(() => { + mock.restore(); + }); + + const setupSelect2 = (input) => { + input.select2({ + ajax: { + url: TEST_URL, + quietMillis: 250, + transport: select2AxiosTransport, + data(search, page) { + return { + search, + page, + ...TEST_SEARCH_DATA, + }; + }, + results: resultsSpy, + }, + }); + }; + + const setupSelect2AndSearch = async () => { + const $input = $('#root'); + + setupSelect2($input); + + $input.select2('search', TEST_SEARCH); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + describe('select2AxiosTransport', () => { + it('uses axios to make request', async () => { + // setup mock response + const replySpy = jest.fn(); + mock.onGet(TEST_URL).reply((...args) => replySpy(...args)); + + await setupSelect2AndSearch(); + + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: TEST_URL, + method: 'get', + params: { + page: 1, + search: TEST_SEARCH, + ...TEST_SEARCH_DATA, + }, + }), + ); + }); + + it.each` + headers | pagination + ${{}} | ${{ more: false }} + ${{ 'X-PAGE': '1', 'x-next-page': 2 }} | ${{ more: true }} + `( + 'passes results and pagination to results callback, with headers=$headers', + async ({ headers, pagination }) => { + mock.onGet(TEST_URL).reply(200, TEST_DATA, headers); + + await setupSelect2AndSearch(); + + expect(resultsSpy).toHaveBeenCalledWith( + { results: TEST_DATA, pagination }, + 1, + expect.anything(), + ); + }, + ); + }); +}); diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 30eb752d2d5..f2cd2523ce8 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -84,6 +84,62 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do end end + describe ".stopped_review_apps" do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:old_stopped_review_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) } + let_it_be(:new_stopped_review_env) { create(:environment, :with_review_app, :stopped, project: project) } + let_it_be(:old_active_review_env) { create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) } + let_it_be(:old_stopped_other_env) { create(:environment, :stopped, created_at: 31.days.ago, project: project) } + let_it_be(:new_stopped_other_env) { create(:environment, :stopped, project: project) } + let_it_be(:old_active_other_env) { create(:environment, :available, created_at: 31.days.ago, project: project) } + + let(:before) { 30.days.ago } + let(:limit) { 1000 } + + subject { project.environments.stopped_review_apps(before, limit) } # rubocop: disable RSpec/SingleLineHook + + it { is_expected.to contain_exactly(old_stopped_review_env) } + + context "current timestamp" do + let(:before) { Time.zone.now } + + it { is_expected.to contain_exactly(old_stopped_review_env, new_stopped_review_env) } + end + end + + describe "scheduled deletion" do + let_it_be(:deletable_environment) { create(:environment, auto_delete_at: Time.zone.now) } + let_it_be(:undeletable_environment) { create(:environment, auto_delete_at: nil) } + + describe ".scheduled_for_deletion" do + subject { described_class.scheduled_for_deletion } + + it { is_expected.to contain_exactly(deletable_environment) } + end + + describe ".not_scheduled_for_deletion" do + subject { described_class.not_scheduled_for_deletion } + + it { is_expected.to contain_exactly(undeletable_environment) } + end + + describe ".schedule_to_delete" do + subject { described_class.for_id(deletable_environment).schedule_to_delete } + + it "schedules the record for deletion" do + freeze_time do + subject + + deletable_environment.reload + undeletable_environment.reload + + expect(deletable_environment.auto_delete_at).to eq(1.week.from_now) + expect(undeletable_environment.auto_delete_at).to be_nil + end + end + end + end + describe 'state machine' do it 'invalidates the cache after a change' do expect(environment).to receive(:expire_etag_cache) diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index b1ac8f9eeec..303e510883d 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -265,4 +265,76 @@ RSpec.describe API::Environments do end end end + + describe "DELETE /projects/:id/environments/review_apps" do + shared_examples "delete stopped review environments" do + around do |example| + freeze_time { example.run } + end + + it "deletes the old stopped review apps" do + old_stopped_review_env = create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) + new_stopped_review_env = create(:environment, :with_review_app, :stopped, project: project) + old_active_review_env = create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) + old_stopped_other_env = create(:environment, :stopped, created_at: 31.days.ago, project: project) + new_stopped_other_env = create(:environment, :stopped, project: project) + old_active_other_env = create(:environment, :available, created_at: 31.days.ago, project: project) + + delete api("/projects/#{project.id}/environments/review_apps", current_user), params: { dry_run: false } + project.environments.reload + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response["scheduled_entries"].size).to eq(1) + expect(json_response["scheduled_entries"].first["id"]).to eq(old_stopped_review_env.id) + expect(json_response["unprocessable_entries"].size).to eq(0) + + expect(old_stopped_review_env.reload.auto_delete_at).to eq(1.week.from_now) + expect(new_stopped_review_env.reload.auto_delete_at).to be_nil + expect(old_active_review_env.reload.auto_delete_at).to be_nil + expect(old_stopped_other_env.reload.auto_delete_at).to be_nil + expect(new_stopped_other_env.reload.auto_delete_at).to be_nil + expect(old_active_other_env.reload.auto_delete_at).to be_nil + end + end + + context "as a maintainer" do + it_behaves_like "delete stopped review environments" do + let(:current_user) { user } + end + end + + context "as a developer" do + let(:developer) { create(:user) } + + before do + project.add_developer(developer) + end + + it_behaves_like "delete stopped review environments" do + let(:current_user) { developer } + end + end + + context "as a reporter" do + let(:reporter) { create(:user) } + + before do + project.add_reporter(reporter) + end + + it "rejects the request" do + delete api("/projects/#{project.id}/environments/review_apps", reporter) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context "as a non member" do + it "rejects the request" do + delete api("/projects/#{project.id}/environments/review_apps", non_member) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end end diff --git a/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb new file mode 100644 index 00000000000..401d6203b2c --- /dev/null +++ b/spec/services/environments/schedule_to_delete_review_apps_service_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Environments::ScheduleToDeleteReviewAppsService do + include ExclusiveLeaseHelpers + + let_it_be(:maintainer) { create(:user) } + let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:project) { create(:project, :private, :repository, namespace: maintainer.namespace) } + + let(:service) { described_class.new(project, current_user, before: 30.days.ago, dry_run: dry_run) } + let(:dry_run) { false } + let(:current_user) { maintainer } + + before do + project.add_maintainer(maintainer) + project.add_developer(developer) + project.add_reporter(reporter) + end + + describe "#execute" do + subject { service.execute } + + shared_examples "can schedule for deletion" do + let!(:old_stopped_review_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) } + let!(:new_stopped_review_env) { create(:environment, :with_review_app, :stopped, project: project) } + let!(:old_active_review_env) { create(:environment, :with_review_app, :available, created_at: 31.days.ago, project: project) } + let!(:old_stopped_other_env) { create(:environment, :stopped, created_at: 31.days.ago, project: project) } + let!(:new_stopped_other_env) { create(:environment, :stopped, project: project) } + let!(:old_active_other_env) { create(:environment, :available, created_at: 31.days.ago, project: project) } + let!(:already_deleting_env) { create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project, auto_delete_at: 1.day.from_now) } + let(:already_deleting_time) { already_deleting_env.reload.auto_delete_at } + + context "live run" do + let(:dry_run) { false } + + around do |example| + freeze_time { example.run } + end + + it "marks the correct environment as scheduled_entries" do + expect(subject.success?).to be_truthy + expect(subject.scheduled_entries).to contain_exactly(old_stopped_review_env) + expect(subject.unprocessable_entries).to be_empty + + old_stopped_review_env.reload + new_stopped_review_env.reload + old_active_review_env.reload + old_stopped_other_env.reload + new_stopped_other_env.reload + old_active_other_env.reload + already_deleting_env.reload + + expect(old_stopped_review_env.auto_delete_at).to eq(1.week.from_now) + expect(new_stopped_review_env.auto_delete_at).to be_nil + expect(old_active_review_env.auto_delete_at).to be_nil + expect(old_stopped_other_env.auto_delete_at).to be_nil + expect(new_stopped_other_env.auto_delete_at).to be_nil + expect(old_active_other_env.auto_delete_at).to be_nil + expect(already_deleting_env.auto_delete_at).to eq(already_deleting_time) + end + end + + context "dry run" do + let(:dry_run) { true } + + it "returns the same but doesn't update the record" do + expect(subject.success?).to be_truthy + expect(subject.scheduled_entries).to contain_exactly(old_stopped_review_env) + expect(subject.unprocessable_entries).to be_empty + + old_stopped_review_env.reload + new_stopped_review_env.reload + old_active_review_env.reload + old_stopped_other_env.reload + new_stopped_other_env.reload + old_active_other_env.reload + already_deleting_env.reload + + expect(old_stopped_review_env.auto_delete_at).to be_nil + expect(new_stopped_review_env.auto_delete_at).to be_nil + expect(old_active_review_env.auto_delete_at).to be_nil + expect(old_stopped_other_env.auto_delete_at).to be_nil + expect(new_stopped_other_env.auto_delete_at).to be_nil + expect(old_active_other_env.auto_delete_at).to be_nil + expect(already_deleting_env.auto_delete_at).to eq(already_deleting_time) + end + end + + describe "execution in parallel" do + before do + stub_exclusive_lease_taken(service.send(:key)) + end + + it "does not execute unsafe_mark_scheduled_entries_environments" do + expect(service).not_to receive(:unsafe_mark_scheduled_entries_environments) + + expect(subject.success?).to be_falsey + expect(subject.status).to eq(:conflict) + end + end + end + + context "as a maintainer" do + let(:current_user) { maintainer } + + it_behaves_like "can schedule for deletion" + end + + context "as a developer" do + let(:current_user) { developer } + + it_behaves_like "can schedule for deletion" + end + + context "as a reporter" do + let(:current_user) { reporter } + + it "fails to delete environments" do + old_stopped_review_env = create(:environment, :with_review_app, :stopped, created_at: 31.days.ago, project: project) + + expect(subject.success?).to be_falsey + + # Both of these should be empty as we fail before testing them + expect(subject.scheduled_entries).to be_empty + expect(subject.unprocessable_entries).to be_empty + + old_stopped_review_env.reload + + expect(old_stopped_review_env.auto_delete_at).to be_nil + end + end + end +end