+ {{
+ s__(
+ 'Environments|Select which environments to clean up. \
+ Protected environments are excluded. Learn more about cleaning up environments.',
+ )
+ }}
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index c4d02da9d214e8f179a98a1b6493894d11f0c1c6..f010898073c769e0a3f50cb8ab211a1fc97cf723 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -1,4 +1,5 @@
import { __, s__ } from '~/locale';
+import { getDateInPast } from '~/lib/utils/datetime_utility';
// These statuses are based on how the backend defines pod phases here
// lib/gitlab/kubernetes/pod.rb
@@ -77,3 +78,6 @@ export const REVIEW_APP_MODAL_I18N = {
viewMoreExampleProjects: s__('EnableReviewApp|View more example projects'),
copyToClipboardText: s__('EnableReviewApp|Copy snippet'),
};
+
+export const MIN_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 3650); // 10 years ago
+export const MAX_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 7); // one week ago
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
index 1a572208a1c104245f37620dd791f316af65cce2..7a50ded7d6cbdb9f9e7db8268e1b60fd35491d98 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
@@ -4,5 +4,6 @@ query getEnvironmentApp($page: Int, $scope: String, $search: String) {
stoppedCount
environments
reviewApp
+ canStopStaleEnvironments
}
}
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index afd56d0cf0d8b7c62a2f986130d7a037df375366..e21670870b8715458a1a71e86bb520495eded3f3 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -54,6 +54,7 @@ export const resolvers = (endpoint) => ({
...convertObjectPropsToCamelCase(res.data.review_app),
__typename: 'ReviewApp',
},
+ canStopStaleEnvironments: res.data.can_stop_stale_environments,
stoppedCount: res.data.stopped_count,
__typename: 'LocalEnvironmentApp',
};
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 7b5babdd3a6b242a0371bd1be80258e4148c7eef..87996d0bb851d60050bf76059cea30458c86644f 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -7,6 +7,7 @@ export * from './api/namespaces_api';
export * from './api/tags_api';
export * from './api/alert_management_alerts_api';
export * from './api/harbor_registry';
+export * from './api/environments_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index f4e30f052617518a134b381644685e34ba1ad246..af7812fcebf6410ae586f2600c2a6942955228e3 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -19,6 +19,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action only: [:show] do
push_frontend_feature_flag(:environment_details_vue, @project)
end
+
before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect]
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_stop_environment!, only: [:stop]
@@ -57,6 +58,8 @@ def index
render json: {
environments: serialize_environments(request, response, params[:nested]),
review_app: serialize_review_app,
+ can_stop_stale_environments: Feature.enabled?(:stop_stale_environments, @project) &&
+ can?(current_user, :stop_environment, @project),
available_count: environments_count_by_state[:available],
stopped_count: environments_count_by_state[:stopped]
}
diff --git a/config/feature_flags/development/stop_stale_environments.yml b/config/feature_flags/development/stop_stale_environments.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ea1484f09702e8fa44c407604d498ca1162948a4
--- /dev/null
+++ b/config/feature_flags/development/stop_stale_environments.yml
@@ -0,0 +1,8 @@
+---
+name: stop_stale_environments
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108616
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/387820
+milestone: '15.8'
+type: development
+group: group::release
+default_enabled: false
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9c0f5505c8c18de62dbc19e8f6fecbb8b0233428..7a8ae0706a0b870f6d25eb6244680719953873b9 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -15718,6 +15718,12 @@ msgstr ""
msgid "Environments|Auto stops %{autoStopAt}"
msgstr ""
+msgid "Environments|Clean up"
+msgstr ""
+
+msgid "Environments|Clean up environments"
+msgstr ""
+
msgid "Environments|Commit"
msgstr ""
@@ -15811,6 +15817,9 @@ msgstr ""
msgid "Environments|Search by environment name"
msgstr ""
+msgid "Environments|Select which environments to clean up. Protected environments are excluded. Learn more about cleaning up environments."
+msgstr ""
+
msgid "Environments|Show all"
msgstr ""
@@ -15820,6 +15829,12 @@ msgstr ""
msgid "Environments|Stop environment"
msgstr ""
+msgid "Environments|Stop environments that have not been updated since the specified date:"
+msgstr ""
+
+msgid "Environments|Stop unused environments"
+msgstr ""
+
msgid "Environments|Stopping %{environmentName}"
msgstr ""
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index 62e8c600e9feebc154792e921514ad01588bcefb..4e60381c4a97cf42b0d0ad4704f68edcebf8dfb3 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Projects::EnvironmentsController do
+RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_delivery do
include MetricsDashboardHelpers
include KubernetesHelpers
@@ -103,6 +103,38 @@
expect(json_response['stopped_count']).to eq 1
end
+ context 'can access stop stale environments feature' do
+ context 'when stop_stale_environments FF is enabled' do
+ it 'maintainers can access the feature' do
+ get :index, params: environment_params(format: :json)
+
+ expect(json_response['can_stop_stale_environments']).to be_truthy
+ end
+
+ context 'when user is a reporter' do
+ let(:user) { reporter }
+
+ it 'reporters cannot access the feature' do
+ get :index, params: environment_params(format: :json)
+
+ expect(json_response['can_stop_stale_environments']).to be_falsey
+ end
+ end
+ end
+
+ context 'when stop_stale_environments FF is disabled' do
+ before do
+ stub_feature_flags(stop_stale_environments: false)
+ end
+
+ it 'maintainers cannot access the feature' do
+ get :index, params: environment_params(format: :json)
+
+ expect(json_response['can_stop_stale_environments']).to be_falsey
+ end
+ end
+ end
+
context 'when enable_environments_search_within_folder FF is disabled' do
before do
stub_feature_flags(enable_environments_search_within_folder: false)
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index 65a9f2907d2cceb7d5db1d226e48be4a9adad197..986ecca4e84810d0b4046d7019e829522371b667 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -195,6 +195,36 @@ describe('~/environments/components/environments_app.vue', () => {
expect(button.exists()).toBe(false);
});
+ it('should not show a button to clean up environments if the user has no permissions', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: {
+ ...resolvedEnvironmentsApp,
+ canStopStaleEnvironments: false,
+ },
+ folder: resolvedFolder,
+ });
+
+ const button = wrapper.findByRole('button', {
+ name: s__('Environments|Clean up environments'),
+ });
+ expect(button.exists()).toBe(false);
+ });
+
+ it('should show a button to clean up environments if the user has permissions', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: {
+ ...resolvedEnvironmentsApp,
+ canStopStaleEnvironments: true,
+ },
+ folder: resolvedFolder,
+ });
+
+ const button = wrapper.findByRole('button', {
+ name: s__('Environments|Clean up environments'),
+ });
+ expect(button.exists()).toBe(true);
+ });
+
describe('tabs', () => {
it('should show tabs for available and stopped environmets', async () => {
await createWrapperWithMocked({
diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js
index 355b77b55c323c264b68cbc7b77597125545310c..0b1c8dc6fb840ebb6166aa896ecbebfcfa1b4488 100644
--- a/spec/frontend/environments/graphql/mock_data.js
+++ b/spec/frontend/environments/graphql/mock_data.js
@@ -265,6 +265,7 @@ export const environmentsApp = {
review_snippet:
'{"deploy_review"=>{"stage"=>"deploy", "script"=>["echo \\"Deploy a review app\\""], "environment"=>{"name"=>"review/$CI_COMMIT_REF_NAME", "url"=>"https://$CI_ENVIRONMENT_SLUG.example.com"}, "only"=>["branches"]}}',
},
+ can_stop_stale_environments: true,
available_count: 4,
stopped_count: 0,
};
@@ -474,6 +475,7 @@ export const resolvedEnvironmentsApp = {
'{"deploy_review"=>{"stage"=>"deploy", "script"=>["echo \\"Deploy a review app\\""], "environment"=>{"name"=>"review/$CI_COMMIT_REF_NAME", "url"=>"https://$CI_ENVIRONMENT_SLUG.example.com"}, "only"=>["branches"]}}',
__typename: 'ReviewApp',
},
+ canStopStaleEnvironments: true,
stoppedCount: 0,
__typename: 'LocalEnvironmentApp',
};
diff --git a/spec/frontend/environments/stop_stale_environments_modal_spec.js b/spec/frontend/environments/stop_stale_environments_modal_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a2ab4f707b523ec055d6b5e1fd9be68dda5f2f39
--- /dev/null
+++ b/spec/frontend/environments/stop_stale_environments_modal_spec.js
@@ -0,0 +1,60 @@
+import MockAdapter from 'axios-mock-adapter';
+import { GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import StopStaleEnvironmentsModal from '~/environments/components/stop_stale_environments_modal.vue';
+import axios from '~/lib/utils/axios_utils';
+import { getDateInPast } from '~/lib/utils/datetime_utility';
+import { STOP_STALE_ENVIRONMENTS_PATH } from '~/api/environments_api';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
+
+const DEFAULT_OPTS = {
+ provide: { projectId: 1 },
+};
+
+const ONE_WEEK_AGO = getDateInPast(new Date(), 7);
+const TEN_YEARS_AGO = getDateInPast(new Date(), 3650);
+
+describe('~/environments/components/stop_stale_environments_modal.vue', () => {
+ let wrapper;
+ let mock;
+ let before;
+ let originalGon;
+
+ const createWrapper = (opts = {}) =>
+ shallowMount(StopStaleEnvironmentsModal, {
+ ...DEFAULT_OPTS,
+ ...opts,
+ propsData: { modalId: 'stop-stale-environments-modal', visible: true },
+ });
+
+ beforeEach(() => {
+ originalGon = window.gon;
+ window.gon = { api_version: 'v4' };
+
+ mock = new MockAdapter(axios);
+ jest.spyOn(axios, 'post');
+ wrapper = createWrapper();
+ before = wrapper.find("[data-testid='stop-environments-before']");
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ jest.resetAllMocks();
+ window.gon = originalGon;
+ });
+
+ it('sets the correct min and max dates', async () => {
+ expect(before.props().minDate.toISOString()).toBe(TEN_YEARS_AGO.toISOString());
+ expect(before.props().maxDate.toISOString()).toBe(ONE_WEEK_AGO.toISOString());
+ });
+
+ it('requests cleanup when submit is clicked', async () => {
+ mock.onPost().replyOnce(HTTP_STATUS_OK);
+ wrapper.findComponent(GlModal).vm.$emit('primary');
+ const url = STOP_STALE_ENVIRONMENTS_PATH.replace(':id', 1).replace(':version', 'v4');
+ expect(axios.post).toHaveBeenCalledWith(url, null, {
+ params: { before: ONE_WEEK_AGO.toISOString() },
+ });
+ });
+});