Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
723dc8aced
commit
f5703a054c
33 changed files with 602 additions and 86 deletions
3
Gemfile
3
Gemfile
|
@ -25,8 +25,7 @@ gem 'marginalia', '~> 1.10.0'
|
||||||
|
|
||||||
# Authentication libraries
|
# Authentication libraries
|
||||||
gem 'devise', '~> 4.7.2'
|
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', '>= 3.1.14'
|
||||||
gem 'bcrypt', '3.1.12'
|
|
||||||
gem 'doorkeeper', '~> 5.5.0.rc2'
|
gem 'doorkeeper', '~> 5.5.0.rc2'
|
||||||
gem 'doorkeeper-openid_connect', '~> 1.7.5'
|
gem 'doorkeeper-openid_connect', '~> 1.7.5'
|
||||||
gem 'omniauth', '~> 1.8'
|
gem 'omniauth', '~> 1.8'
|
||||||
|
|
|
@ -128,7 +128,7 @@ GEM
|
||||||
babosa (1.0.2)
|
babosa (1.0.2)
|
||||||
base32 (0.3.2)
|
base32 (0.3.2)
|
||||||
batch-loader (2.0.1)
|
batch-loader (2.0.1)
|
||||||
bcrypt (3.1.12)
|
bcrypt (3.1.16)
|
||||||
bcrypt_pbkdf (1.0.0)
|
bcrypt_pbkdf (1.0.0)
|
||||||
benchmark-ips (2.3.0)
|
benchmark-ips (2.3.0)
|
||||||
benchmark-memory (0.1.2)
|
benchmark-memory (0.1.2)
|
||||||
|
@ -1346,7 +1346,7 @@ DEPENDENCIES
|
||||||
babosa (~> 1.0.2)
|
babosa (~> 1.0.2)
|
||||||
base32 (~> 0.3.0)
|
base32 (~> 0.3.0)
|
||||||
batch-loader (~> 2.0.1)
|
batch-loader (~> 2.0.1)
|
||||||
bcrypt (= 3.1.12)
|
bcrypt (~> 3.1, >= 3.1.14)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
benchmark-ips (~> 2.3.0)
|
benchmark-ips (~> 2.3.0)
|
||||||
benchmark-memory (~> 0.1)
|
benchmark-memory (~> 0.1)
|
||||||
|
|
|
@ -2,30 +2,8 @@ import $ from 'jquery';
|
||||||
import { escape } from 'lodash';
|
import { escape } from 'lodash';
|
||||||
import { __ } from '~/locale';
|
import { __ } from '~/locale';
|
||||||
import Api from './api';
|
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';
|
import { loadCSSFile } from './lib/utils/css_utils';
|
||||||
|
import { select2AxiosTransport } from './lib/utils/select2_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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupsSelect = () => {
|
const groupsSelect = () => {
|
||||||
loadCSSFile(gon.select2_css_path)
|
loadCSSFile(gon.select2_css_path)
|
||||||
|
@ -51,9 +29,7 @@ const groupsSelect = () => {
|
||||||
url: Api.buildUrl(groupsPath),
|
url: Api.buildUrl(groupsPath),
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
quietMillis: 250,
|
quietMillis: 250,
|
||||||
transport(params) {
|
transport: select2AxiosTransport,
|
||||||
fetchGroups(params);
|
|
||||||
},
|
|
||||||
data(search, page) {
|
data(search, page) {
|
||||||
return {
|
return {
|
||||||
search,
|
search,
|
||||||
|
@ -63,8 +39,6 @@ const groupsSelect = () => {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
results(data, page) {
|
results(data, page) {
|
||||||
if (data.length) return { results: [] };
|
|
||||||
|
|
||||||
const groups = data.length ? data : data.results || [];
|
const groups = data.length ? data : data.results || [];
|
||||||
const more = data.pagination ? data.pagination.more : false;
|
const more = data.pagination ? data.pagination.more : false;
|
||||||
const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1);
|
const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1);
|
||||||
|
|
|
@ -5,6 +5,7 @@ import Autosave from './autosave';
|
||||||
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
|
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
|
||||||
import { loadCSSFile } from './lib/utils/css_utils';
|
import { loadCSSFile } from './lib/utils/css_utils';
|
||||||
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
|
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
|
||||||
|
import { select2AxiosTransport } from './lib/utils/select2_utils';
|
||||||
import { queryToObject, objectToQuery } from './lib/utils/url_utility';
|
import { queryToObject, objectToQuery } from './lib/utils/url_utility';
|
||||||
import UsersSelect from './users_select';
|
import UsersSelect from './users_select';
|
||||||
import ZenMode from './zen_mode';
|
import ZenMode from './zen_mode';
|
||||||
|
@ -199,15 +200,16 @@ export default class IssuableForm {
|
||||||
search,
|
search,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
results(data) {
|
results({ results }) {
|
||||||
return {
|
return {
|
||||||
// `data` keys are translated so we can't just access them with a string based key
|
// `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,
|
id: name,
|
||||||
text: name,
|
text: name,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
transport: select2AxiosTransport,
|
||||||
},
|
},
|
||||||
initSelection(el, callback) {
|
initSelection(el, callback) {
|
||||||
const val = el.val();
|
const val = el.val();
|
||||||
|
|
25
app/assets/javascripts/lib/utils/select2_utils.js
Normal file
25
app/assets/javascripts/lib/utils/select2_utils.js
Normal file
|
@ -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);
|
||||||
|
};
|
|
@ -96,6 +96,22 @@ class Environment < ApplicationRecord
|
||||||
end
|
end
|
||||||
scope :for_id, -> (id) { where(id: id) }
|
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: {
|
enum tier: {
|
||||||
production: 0,
|
production: 0,
|
||||||
staging: 1,
|
staging: 1,
|
||||||
|
@ -147,6 +163,10 @@ class Environment < ApplicationRecord
|
||||||
self.state_machine.states.map(&:name)
|
self.state_machine.states.map(&:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.schedule_to_delete(at_time = 1.week.from_now)
|
||||||
|
update_all(auto_delete_at: at_time)
|
||||||
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
##
|
##
|
||||||
# This method returns stop actions (jobs) for multiple environments within one
|
# This method returns stop actions (jobs) for multiple environments within one
|
||||||
|
|
|
@ -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
|
5
changelogs/unreleased/stale-environment-cleanup.yml
Normal file
5
changelogs/unreleased/stale-environment-cleanup.yml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Add API endpoint for deleting stale review envs
|
||||||
|
merge_request: 52224
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Update Pages template examples to default branch
|
||||||
|
merge_request: 56298
|
||||||
|
author:
|
||||||
|
type: other
|
|
@ -62,25 +62,6 @@ their account.
|
||||||
If an administrator [increases](#set-the-user-cap-number) or [removes](#remove-the-user-cap) the
|
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.
|
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
|
### Set the user cap number
|
||||||
|
|
||||||
1. Go to **Admin Area > Settings > General**.
|
1. Go to **Admin Area > Settings > General**.
|
||||||
|
|
|
@ -75,6 +75,33 @@ module API
|
||||||
end
|
end
|
||||||
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
|
desc 'Deletes an existing environment' do
|
||||||
detail 'This feature was introduced in GitLab 8.11.'
|
detail 'This feature was introduced in GitLab 8.11.'
|
||||||
success Entities::Environment
|
success Entities::Environment
|
||||||
|
|
|
@ -11,5 +11,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -9,5 +9,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -13,5 +13,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -8,5 +8,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -11,5 +11,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -13,5 +13,5 @@ pages:
|
||||||
paths:
|
paths:
|
||||||
- node_modules
|
- node_modules
|
||||||
key: project
|
key: project
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -10,7 +10,8 @@ test:
|
||||||
script:
|
script:
|
||||||
- hugo
|
- hugo
|
||||||
except:
|
except:
|
||||||
- master
|
variables:
|
||||||
|
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
script:
|
script:
|
||||||
|
@ -19,4 +20,5 @@ pages:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
only:
|
||||||
- master
|
variables:
|
||||||
|
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -11,7 +11,8 @@ test:
|
||||||
- pip install hyde
|
- pip install hyde
|
||||||
- hyde gen
|
- hyde gen
|
||||||
except:
|
except:
|
||||||
- master
|
variables:
|
||||||
|
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
|
@ -22,4 +23,5 @@ pages:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
only:
|
||||||
- master
|
variables:
|
||||||
|
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -18,7 +18,8 @@ test:
|
||||||
paths:
|
paths:
|
||||||
- test
|
- test
|
||||||
except:
|
except:
|
||||||
- master
|
variables:
|
||||||
|
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
|
@ -28,4 +29,5 @@ pages:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
only:
|
||||||
- master
|
variables:
|
||||||
|
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -33,5 +33,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -8,5 +8,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -12,5 +12,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -12,7 +12,8 @@ test:
|
||||||
- bundle install --path vendor
|
- bundle install --path vendor
|
||||||
- bundle exec middleman build
|
- bundle exec middleman build
|
||||||
except:
|
except:
|
||||||
- master
|
variables:
|
||||||
|
- $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
pages:
|
pages:
|
||||||
script:
|
script:
|
||||||
|
@ -23,5 +24,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -8,5 +8,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -11,5 +11,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -25,5 +25,5 @@ pages:
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
rules:
|
||||||
- master
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
|
@ -4666,6 +4666,9 @@ msgstr ""
|
||||||
msgid "Be careful. Renaming a project's repository can have unintended side effects."
|
msgid "Be careful. Renaming a project's repository can have unintended side effects."
|
||||||
msgstr ""
|
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"
|
msgid "Begin with the selected commit"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,8 @@ FactoryBot.define do
|
||||||
end
|
end
|
||||||
|
|
||||||
trait :with_review_app do |environment|
|
trait :with_review_app do |environment|
|
||||||
|
sequence(:name) { |n| "review/#{n}" }
|
||||||
|
|
||||||
transient do
|
transient do
|
||||||
ref { 'master' }
|
ref { 'master' }
|
||||||
end
|
end
|
||||||
|
|
100
spec/frontend/lib/utils/select2_utils_spec.js
Normal file
100
spec/frontend/lib/utils/select2_utils_spec.js
Normal file
|
@ -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('<div><input id="root" /></div>');
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -84,6 +84,62 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
|
||||||
end
|
end
|
||||||
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
|
describe 'state machine' do
|
||||||
it 'invalidates the cache after a change' do
|
it 'invalidates the cache after a change' do
|
||||||
expect(environment).to receive(:expire_etag_cache)
|
expect(environment).to receive(:expire_etag_cache)
|
||||||
|
|
|
@ -265,4 +265,76 @@ RSpec.describe API::Environments do
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -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
|
Loading…
Reference in a new issue