diff --git a/app/assets/javascripts/api/environments_api.js b/app/assets/javascripts/api/environments_api.js new file mode 100644 index 0000000000000000000000000000000000000000..9912b1ab696333ecca64506e66971e7376bded6a --- /dev/null +++ b/app/assets/javascripts/api/environments_api.js @@ -0,0 +1,15 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +export const STOP_STALE_ENVIRONMENTS_PATH = '/api/:version/projects/:id/environments/stop_stale'; + +export function stopStaleEnvironments(projectId, before, query, options) { + const url = buildApiUrl(STOP_STALE_ENVIRONMENTS_PATH).replace(':id', projectId); + const defaults = { + before: before.toISOString(), + }; + + return axios.post(url, null, { + params: Object.assign(defaults, options), + }); +} diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 55e6a891e270d3218a76fa3917db04b964c7f130..b2a69cdb6c64f2c8ea4c04c125f943e3864b0b97 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -15,6 +15,7 @@ import { ENVIRONMENTS_SCOPE } from '../constants'; import EnvironmentFolder from './environment_folder.vue'; import EnableReviewAppModal from './enable_review_app_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue'; +import StopStaleEnvironmentsModal from './stop_stale_environments_modal.vue'; import EnvironmentItem from './new_environment_item.vue'; import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import DeleteEnvironmentModal from './delete_environment_modal.vue'; @@ -31,6 +32,7 @@ export default { EnableReviewAppModal, EnvironmentItem, StopEnvironmentModal, + StopStaleEnvironmentsModal, GlBadge, GlPagination, GlSearchBoxByType, @@ -75,6 +77,7 @@ export default { i18n: { newEnvironmentButtonLabel: s__('Environments|New environment'), reviewAppButtonLabel: s__('Environments|Enable review app'), + cleanUpEnvsButtonLabel: s__('Environments|Clean up environments'), available: __('Available'), stopped: __('Stopped'), prevPage: __('Go to previous page'), @@ -85,11 +88,13 @@ export default { searchPlaceholder: s__('Environments|Search by environment name'), }, modalId: 'enable-review-app-info', + stopStaleEnvsModalId: 'stop-stale-environments-modal', data() { const { page = '1', search = '', scope } = queryToObject(window.location.search); return { interval: undefined, isReviewAppModalVisible: false, + isStopStaleEnvModalVisible: false, page: parseInt(page, 10), pageInfo: {}, scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope) @@ -107,6 +112,9 @@ export default { canSetupReviewApp() { return this.environmentApp?.reviewApp?.canSetupReviewApp; }, + canCleanUpEnvs() { + return this.environmentApp?.canStopStaleEnvironments; + }, folders() { return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? []; }, @@ -149,6 +157,19 @@ export default { }, }; }, + openCleanUpEnvsModal() { + if (!this.canCleanUpEnvs) { + return null; + } + + return { + text: this.$options.i18n.cleanUpEnvsButtonLabel, + attributes: { + category: 'secondary', + variant: 'confirm', + }, + }; + }, stoppedCount() { return this.environmentApp?.stoppedCount; }, @@ -178,6 +199,9 @@ export default { showReviewAppModal() { this.isReviewAppModalVisible = true; }, + showCleanUpEnvsModal() { + this.isStopStaleEnvModalVisible = true; + }, setScope(scope) { this.scope = scope; this.moveToPage(1); @@ -219,16 +243,24 @@ export default { :modal-id="$options.modalId" data-testid="enable-review-app-modal" /> + +import { GlTooltipDirective, GlModal, GlDatepicker, GlFormGroup } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { stopStaleEnvironments } from '~/rest_api'; +import { MIN_STALE_ENVIRONMENT_DATE, MAX_STALE_ENVIRONMENT_DATE } from '../constants'; + +export default { + id: 'stop-stale-environments-modal', + name: 'StopStaleEnvironmentsModal', + + components: { + GlModal, + GlDatepicker, + GlFormGroup, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + projectId: { + default: '', + }, + }, + model: { + prop: 'visible', + event: 'change', + }, + props: { + modalId: { + type: String, + required: true, + }, + visible: { + type: Boolean, + required: false, + default: false, + }, + }, + modalProps: { + primary: { + text: s__('Environments|Clean up'), + attributes: [{ variant: 'info' }], + }, + cancel: { + text: __('Cancel'), + }, + dateRange: { + minDate: MIN_STALE_ENVIRONMENT_DATE, // 10 years ago + maxDate: MAX_STALE_ENVIRONMENT_DATE, + }, + }, + + data() { + return { + stopEnvironmentsBefore: MAX_STALE_ENVIRONMENT_DATE, + }; + }, + + methods: { + onSubmit() { + stopStaleEnvironments(this.projectId, this.stopEnvironmentsBefore || this.maxDate); + }, + }, +}; + + + 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() }, + }); + }); +});