From d7656ed9abd3275e43e01694717423cb5e13be71 Mon Sep 17 00:00:00 2001 From: Allen Cook Date: Mon, 9 Jan 2023 15:49:50 -0500 Subject: [PATCH 1/2] Add ability for users to stop stale environments --- .../javascripts/api/environments_api.js | 21 ++++ .../components/environments_app.vue | 38 ++++++- .../stop_stale_environments_modal.vue | 104 ++++++++++++++++++ .../javascripts/environments/constants.js | 4 + .../queries/environment_app.query.graphql | 1 + .../environments/graphql/resolvers.js | 1 + app/assets/javascripts/rest_api.js | 1 + .../projects/environments_controller.rb | 3 + .../development/stop_stale_environments.yml | 8 ++ locale/gitlab.pot | 15 +++ .../projects/environments_controller_spec.rb | 34 +++++- .../environments/environments_app_spec.js | 15 +++ .../environments/graphql/mock_data.js | 2 + .../stop_stale_environments_modal_spec.js | 44 ++++++++ 14 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 app/assets/javascripts/api/environments_api.js create mode 100644 app/assets/javascripts/environments/components/stop_stale_environments_modal.vue create mode 100644 config/feature_flags/development/stop_stale_environments.yml create mode 100644 spec/frontend/environments/stop_stale_environments_modal_spec.js diff --git a/app/assets/javascripts/api/environments_api.js b/app/assets/javascripts/api/environments_api.js new file mode 100644 index 00000000000000..7b3392e2ada88d --- /dev/null +++ b/app/assets/javascripts/api/environments_api.js @@ -0,0 +1,21 @@ +import { DEFAULT_PER_PAGE } from '~/api'; +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const STOP_STALE_ENVIRONMENTS_PATH = + '/api/:version/projects/:id/environments/stop_stale?before=:before'; + +export function stopStaleEnvironments(projectId, before, query, options) { + const url = buildApiUrl(STOP_STALE_ENVIRONMENTS_PATH) + .replace(':id', projectId) + .replace(':before', before.toISOString()); + const defaults = { + search: query, + per_page: DEFAULT_PER_PAGE, + simple: true, + }; + + return axios.post(url, { + 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 55e6a891e270d3..b2a69cdb6c64f2 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: null, + }; + }, + + 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 c4d02da9d214e8..f010898073c769 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 1a572208a1c104..7a50ded7d6cbdb 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 afd56d0cf0d8b7..e21670870b8715 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 7b5babdd3a6b24..87996d0bb851d6 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 f4e30f05261751..af7812fcebf641 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 00000000000000..ea1484f09702e8 --- /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 9c0f5505c8c18d..7a8ae0706a0b87 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 62e8c600e9feeb..4e60381c4a97cf 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 65a9f2907d2cce..a32d4ce4d55495 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -195,6 +195,21 @@ 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); + }); + 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 355b77b55c323c..0b1c8dc6fb840e 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 00000000000000..cd8246f30b46e6 --- /dev/null +++ b/spec/frontend/environments/stop_stale_environments_modal_spec.js @@ -0,0 +1,44 @@ +import MockAdapter from 'axios-mock-adapter'; +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'; + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); + +const DEFAULT_OPTS = { + provide: { stopStaleEnvironmentsPath: '/projects/1/environments/stop_stale', 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; + + const createWrapper = (opts = {}) => + shallowMount(StopStaleEnvironmentsModal, { + ...DEFAULT_OPTS, + ...opts, + propsData: { modalId: 'stop-stale-environments-modal', visible: true }, + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createWrapper(); + before = wrapper.find("[data-testid='stop-environments-before']"); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + 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()); + }); +}); -- GitLab From 2e7798e295806a846537cf6f388aab9cd6b3be78 Mon Sep 17 00:00:00 2001 From: Allen Cook Date: Fri, 20 Jan 2023 10:50:47 -0500 Subject: [PATCH 2/2] Add specs for testing submit of cleanup request --- .../javascripts/api/environments_api.js | 14 ++++------- .../stop_stale_environments_modal.vue | 2 +- .../environments/environments_app_spec.js | 15 ++++++++++++ .../stop_stale_environments_modal_spec.js | 24 +++++++++++++++---- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/api/environments_api.js b/app/assets/javascripts/api/environments_api.js index 7b3392e2ada88d..9912b1ab696333 100644 --- a/app/assets/javascripts/api/environments_api.js +++ b/app/assets/javascripts/api/environments_api.js @@ -1,21 +1,15 @@ -import { DEFAULT_PER_PAGE } from '~/api'; import axios from '../lib/utils/axios_utils'; import { buildApiUrl } from './api_utils'; -const STOP_STALE_ENVIRONMENTS_PATH = - '/api/:version/projects/:id/environments/stop_stale?before=:before'; +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) - .replace(':before', before.toISOString()); + const url = buildApiUrl(STOP_STALE_ENVIRONMENTS_PATH).replace(':id', projectId); const defaults = { - search: query, - per_page: DEFAULT_PER_PAGE, - simple: true, + before: before.toISOString(), }; - return axios.post(url, { + return axios.post(url, null, { params: Object.assign(defaults, options), }); } diff --git a/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue index 75af365daa1b5e..57873b28d374e2 100644 --- a/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue +++ b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue @@ -52,7 +52,7 @@ export default { data() { return { - stopEnvironmentsBefore: null, + stopEnvironmentsBefore: MAX_STALE_ENVIRONMENT_DATE, }; }, diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index a32d4ce4d55495..986ecca4e84810 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -210,6 +210,21 @@ describe('~/environments/components/environments_app.vue', () => { 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/stop_stale_environments_modal_spec.js b/spec/frontend/environments/stop_stale_environments_modal_spec.js index cd8246f30b46e6..a2ab4f707b523e 100644 --- a/spec/frontend/environments/stop_stale_environments_modal_spec.js +++ b/spec/frontend/environments/stop_stale_environments_modal_spec.js @@ -1,14 +1,14 @@ 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'; - -jest.mock('~/lib/utils/url_utility'); -jest.mock('~/flash'); +import { STOP_STALE_ENVIRONMENTS_PATH } from '~/api/environments_api'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; const DEFAULT_OPTS = { - provide: { stopStaleEnvironmentsPath: '/projects/1/environments/stop_stale', projectId: 1 }, + provide: { projectId: 1 }, }; const ONE_WEEK_AGO = getDateInPast(new Date(), 7); @@ -18,6 +18,7 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => { let wrapper; let mock; let before; + let originalGon; const createWrapper = (opts = {}) => shallowMount(StopStaleEnvironmentsModal, { @@ -27,7 +28,11 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => { }); 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']"); }); @@ -35,10 +40,21 @@ describe('~/environments/components/stop_stale_environments_modal.vue', () => { 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() }, + }); + }); }); -- GitLab