Commit 550d687c authored by Shinya Maeda's avatar Shinya Maeda

Merge branch '20620-delete-an-environment-via-ui' into 'master'

Resolve "Delete an environment via UI"

Closes #20620

See merge request gitlab-org/gitlab!22629
parents a2581af6 6a093dcf
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
id: 'delete-environment-modal',
name: 'DeleteEnvironmentModal',
components: {
GlModal,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
environment: {
type: Object,
required: true,
},
},
computed: {
confirmDeleteMessage() {
return sprintf(
s__(
`Environments|Deleting the '%{environmentName}' environment cannot be undone. Do you want to delete it anyway?`,
),
{
environmentName: this.environment.name,
},
false,
);
},
},
methods: {
onSubmit() {
eventHub.$emit('deleteEnvironment', this.environment);
},
},
};
</script>
<template>
<gl-modal
:id="$options.id"
:footer-primary-button-text="s__('Environments|Delete environment')"
footer-primary-button-variant="danger"
@submit="onSubmit"
>
<template slot="header">
<h4 class="modal-title d-flex mw-100">
{{ __('Delete') }}
<span v-gl-tooltip :title="environment.name" class="text-truncate mx-1 flex-fill">
{{ environment.name }}?
</span>
</h4>
</template>
<p>{{ confirmDeleteMessage }}</p>
</gl-modal>
</template>
<script>
/**
* Renders the delete button that allows deleting a stopped environment.
* Used in the environments table and the environment detail view.
*/
import $ from 'jquery';
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
export default {
components: {
Icon,
LoadingButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
environment: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
title() {
return s__('Environments|Delete environment');
},
},
mounted() {
eventHub.$on('deleteEnvironment', this.onDeleteEnvironment);
},
beforeDestroy() {
eventHub.$off('deleteEnvironment', this.onDeleteEnvironment);
},
methods: {
onClick() {
$(this.$el).tooltip('dispose');
eventHub.$emit('requestDeleteEnvironment', this.environment);
},
onDeleteEnvironment(environment) {
if (this.environment.id === environment.id) {
this.isLoading = true;
}
},
},
};
</script>
<template>
<loading-button
v-gl-tooltip
:loading="isLoading"
:title="title"
:aria-label="title"
container-class="btn btn-danger d-none d-sm-none d-md-block"
data-toggle="modal"
data-target="#delete-environment-modal"
@click="onClick"
>
<icon name="remove" />
</loading-button>
</template>
...@@ -15,8 +15,9 @@ import ActionsComponent from './environment_actions.vue'; ...@@ -15,8 +15,9 @@ import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue'; import ExternalUrlComponent from './environment_external_url.vue';
import MonitoringButtonComponent from './environment_monitoring.vue'; import MonitoringButtonComponent from './environment_monitoring.vue';
import PinComponent from './environment_pin.vue'; import PinComponent from './environment_pin.vue';
import RollbackComponent from './environment_rollback.vue'; import DeleteComponent from './environment_delete.vue';
import StopComponent from './environment_stop.vue'; import StopComponent from './environment_stop.vue';
import RollbackComponent from './environment_rollback.vue';
import TerminalButtonComponent from './environment_terminal_button.vue'; import TerminalButtonComponent from './environment_terminal_button.vue';
/** /**
...@@ -33,6 +34,7 @@ export default { ...@@ -33,6 +34,7 @@ export default {
Icon, Icon,
MonitoringButtonComponent, MonitoringButtonComponent,
PinComponent, PinComponent,
DeleteComponent,
RollbackComponent, RollbackComponent,
StopComponent, StopComponent,
TerminalButtonComponent, TerminalButtonComponent,
...@@ -112,6 +114,15 @@ export default { ...@@ -112,6 +114,15 @@ export default {
return this.model && this.model.can_stop; return this.model && this.model.can_stop;
}, },
/**
* Returns whether the environment can be deleted.
*
* @returns {Boolean}
*/
canDeleteEnvironment() {
return Boolean(this.model && this.model.can_delete && this.model.delete_path);
},
/** /**
* Verifies if the `deployable` key is present in `last_deployment` key. * Verifies if the `deployable` key is present in `last_deployment` key.
* Used to verify whether we should or not render the rollback partial. * Used to verify whether we should or not render the rollback partial.
...@@ -485,6 +496,7 @@ export default { ...@@ -485,6 +496,7 @@ export default {
this.externalURL || this.externalURL ||
this.monitoringUrl || this.monitoringUrl ||
this.canStopEnvironment || this.canStopEnvironment ||
this.canDeleteEnvironment ||
this.canRetry this.canRetry
); );
}, },
...@@ -680,6 +692,8 @@ export default { ...@@ -680,6 +692,8 @@ export default {
/> />
<stop-component v-if="canStopEnvironment" :environment="model" /> <stop-component v-if="canStopEnvironment" :environment="model" />
<delete-component v-if="canDeleteEnvironment" :environment="model" />
</div> </div>
</div> </div>
</div> </div>
......
...@@ -9,6 +9,7 @@ import environmentsMixin from '../mixins/environments_mixin'; ...@@ -9,6 +9,7 @@ import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
import EnableReviewAppButton from './enable_review_app_button.vue'; import EnableReviewAppButton from './enable_review_app_button.vue';
import StopEnvironmentModal from './stop_environment_modal.vue'; import StopEnvironmentModal from './stop_environment_modal.vue';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import ConfirmRollbackModal from './confirm_rollback_modal.vue';
export default { export default {
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
EnableReviewAppButton, EnableReviewAppButton,
GlButton, GlButton,
StopEnvironmentModal, StopEnvironmentModal,
DeleteEnvironmentModal,
}, },
mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin], mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin],
...@@ -95,6 +97,7 @@ export default { ...@@ -95,6 +97,7 @@ export default {
<template> <template>
<div> <div>
<stop-environment-modal :environment="environmentInStopModal" /> <stop-environment-modal :environment="environmentInStopModal" />
<delete-environment-modal :environment="environmentInDeleteModal" />
<confirm-rollback-modal :environment="environmentInRollbackModal" /> <confirm-rollback-modal :environment="environmentInRollbackModal" />
<div class="top-area"> <div class="top-area">
......
...@@ -63,10 +63,9 @@ export default { ...@@ -63,10 +63,9 @@ export default {
<template slot="header"> <template slot="header">
<h4 class="modal-title d-flex mw-100"> <h4 class="modal-title d-flex mw-100">
Stopping Stopping
<span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill">{{ <span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill">
environment.name {{ environment.name }}?
}}</span> </span>
?
</h4> </h4>
</template> </template>
......
...@@ -3,10 +3,12 @@ import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view ...@@ -3,10 +3,12 @@ import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view
import environmentsMixin from '../mixins/environments_mixin'; import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue'; import StopEnvironmentModal from '../components/stop_environment_modal.vue';
import DeleteEnvironmentModal from '../components/delete_environment_modal.vue';
export default { export default {
components: { components: {
StopEnvironmentModal, StopEnvironmentModal,
DeleteEnvironmentModal,
}, },
mixins: [environmentsMixin, CIPaginationMixin, folderMixin], mixins: [environmentsMixin, CIPaginationMixin, folderMixin],
...@@ -39,6 +41,7 @@ export default { ...@@ -39,6 +41,7 @@ export default {
<template> <template>
<div :class="cssContainerClass"> <div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" /> <stop-environment-modal :environment="environmentInStopModal" />
<delete-environment-modal :environment="environmentInDeleteModal" />
<h4 class="js-folder-name environments-folder-name"> <h4 class="js-folder-name environments-folder-name">
{{ s__('Environments|Environments') }} / {{ s__('Environments|Environments') }} /
......
...@@ -27,6 +27,10 @@ export default { ...@@ -27,6 +27,10 @@ export default {
data() { data() {
const store = new EnvironmentsStore(); const store = new EnvironmentsStore();
const isDetailView = document.body.contains(
document.getElementById('environments-detail-view'),
);
return { return {
store, store,
state: store.state, state: store.state,
...@@ -36,7 +40,9 @@ export default { ...@@ -36,7 +40,9 @@ export default {
page: getParameterByName('page') || '1', page: getParameterByName('page') || '1',
requestData: {}, requestData: {},
environmentInStopModal: {}, environmentInStopModal: {},
environmentInDeleteModal: {},
environmentInRollbackModal: {}, environmentInRollbackModal: {},
isDetailView,
}; };
}, },
...@@ -121,6 +127,10 @@ export default { ...@@ -121,6 +127,10 @@ export default {
this.environmentInStopModal = environment; this.environmentInStopModal = environment;
}, },
updateDeleteModal(environment) {
this.environmentInDeleteModal = environment;
},
updateRollbackModal(environment) { updateRollbackModal(environment) {
this.environmentInRollbackModal = environment; this.environmentInRollbackModal = environment;
}, },
...@@ -133,6 +143,30 @@ export default { ...@@ -133,6 +143,30 @@ export default {
this.postAction({ endpoint, errorMessage }); this.postAction({ endpoint, errorMessage });
}, },
deleteEnvironment(environment) {
const endpoint = environment.delete_path;
const mountedToShow = environment.mounted_to_show;
const errorMessage = s__(
'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.',
);
this.service
.deleteAction(endpoint)
.then(() => {
if (!mountedToShow) {
// Reload as a first solution to bust the ETag cache
window.location.reload();
return;
}
const url = window.location.href.split('/');
url.pop();
window.location.href = url.join('/');
})
.catch(() => {
Flash(errorMessage);
});
},
rollbackEnvironment(environment) { rollbackEnvironment(environment) {
const { retryUrl, isLastDeployment } = environment; const { retryUrl, isLastDeployment } = environment;
const errorMessage = isLastDeployment const errorMessage = isLastDeployment
...@@ -178,36 +212,42 @@ export default { ...@@ -178,36 +212,42 @@ export default {
this.service = new EnvironmentsService(this.endpoint); this.service = new EnvironmentsService(this.endpoint);
this.requestData = { page: this.page, scope: this.scope, nested: true }; this.requestData = { page: this.page, scope: this.scope, nested: true };
this.poll = new Poll({ if (!this.isDetailView) {
resource: this.service, this.poll = new Poll({
method: 'fetchEnvironments', resource: this.service,
data: this.requestData, method: 'fetchEnvironments',
successCallback: this.successCallback, data: this.requestData,
errorCallback: this.errorCallback, successCallback: this.successCallback,
notificationCallback: isMakingRequest => { errorCallback: this.errorCallback,
this.isMakingRequest = isMakingRequest; notificationCallback: isMakingRequest => {
}, this.isMakingRequest = isMakingRequest;
}); },
});
if (!Visibility.hidden()) {
this.isLoading = true;
this.poll.makeRequest();
} else {
this.fetchEnvironments();
}
Visibility.change(() => {
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
this.poll.restart(); this.isLoading = true;
this.poll.makeRequest();
} else { } else {
this.poll.stop(); this.fetchEnvironments();
} }
});
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
}
eventHub.$on('postAction', this.postAction); eventHub.$on('postAction', this.postAction);
eventHub.$on('requestStopEnvironment', this.updateStopModal); eventHub.$on('requestStopEnvironment', this.updateStopModal);
eventHub.$on('stopEnvironment', this.stopEnvironment); eventHub.$on('stopEnvironment', this.stopEnvironment);
eventHub.$on('requestDeleteEnvironment', this.updateDeleteModal);
eventHub.$on('deleteEnvironment', this.deleteEnvironment);
eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
...@@ -216,9 +256,13 @@ export default { ...@@ -216,9 +256,13 @@ export default {
beforeDestroy() { beforeDestroy() {
eventHub.$off('postAction', this.postAction); eventHub.$off('postAction', this.postAction);
eventHub.$off('requestStopEnvironment', this.updateStopModal); eventHub.$off('requestStopEnvironment', this.updateStopModal);
eventHub.$off('stopEnvironment', this.stopEnvironment); eventHub.$off('stopEnvironment', this.stopEnvironment);
eventHub.$off('requestDeleteEnvironment', this.updateDeleteModal);
eventHub.$off('deleteEnvironment', this.deleteEnvironment);
eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
......
import Vue from 'vue';
import DeleteEnvironmentModal from './components/delete_environment_modal.vue';
import environmentsMixin from './mixins/environments_mixin';
export default () => {
const el = document.getElementById('delete-environment-modal');
const container = document.getElementById('environments-detail-view');
return new Vue({
el,
components: {
DeleteEnvironmentModal,
},
mixins: [environmentsMixin],
data() {
const environment = JSON.parse(JSON.stringify(container.dataset));
environment.delete_path = environment.deletePath;
environment.mounted_to_show = true;
return {
environment,
};
},
render(createElement) {
return createElement('delete-environment-modal', {
props: {
environment: this.environment,
},
});
},
});
};
...@@ -16,6 +16,11 @@ export default class EnvironmentsService { ...@@ -16,6 +16,11 @@ export default class EnvironmentsService {
return axios.post(endpoint, {}); return axios.post(endpoint, {});
} }
// eslint-disable-next-line class-methods-use-this
deleteAction(endpoint) {
return axios.delete(endpoint, {});
}
getFolderContent(folderUrl) { getFolderContent(folderUrl) {
return axios.get(`${folderUrl}.json?per_page=${this.folderResults}`); return axios.get(`${folderUrl}.json?per_page=${this.folderResults}`);
} }
......
import initShowEnvironment from '~/environments/mount_show';
document.addEventListener('DOMContentLoaded', () => initShowEnvironment());
...@@ -50,4 +50,8 @@ module EnvironmentsHelper ...@@ -50,4 +50,8 @@ module EnvironmentsHelper
"cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack') "cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack')
} }
end end
def can_destroy_environment?(environment)
can?(current_user, :destroy_environment, environment)
end
end end
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
module GitlabRoutingHelper module GitlabRoutingHelper
extend ActiveSupport::Concern extend ActiveSupport::Concern
include API::Helpers::RelatedResourcesHelpers
included do included do
Gitlab::Routing.includes_helpers(self) Gitlab::Routing.includes_helpers(self)
end end
...@@ -29,6 +30,10 @@ module GitlabRoutingHelper ...@@ -29,6 +30,10 @@ module GitlabRoutingHelper
metrics_project_environment_path(environment.project, environment, *args) metrics_project_environment_path(environment.project, environment, *args)
end end
def environment_delete_path(environment, *args)
expose_path(api_v4_projects_environments_path(id: environment.project.id, environment_id: environment.id))
end
def issue_path(entity, *args) def issue_path(entity, *args)
project_issue_path(entity.project, entity, *args) project_issue_path(entity.project, entity, *args)
end end
......
...@@ -12,7 +12,13 @@ class EnvironmentPolicy < BasePolicy ...@@ -12,7 +12,13 @@ class EnvironmentPolicy < BasePolicy
!@subject.stop_action_available? && can?(:update_environment, @subject) !@subject.stop_action_available? && can?(:update_environment, @subject)
end end
condition(:stopped) do
@subject.stopped?
end
rule { stop_with_deployment_allowed | stop_with_update_allowed }.enable :stop_environment rule { stop_with_deployment_allowed | stop_with_update_allowed }.enable :stop_environment
rule { ~stopped }.prevent(:destroy_environment)
end end
EnvironmentPolicy.prepend_if_ee('EE::EnvironmentPolicy') EnvironmentPolicy.prepend_if_ee('EE::EnvironmentPolicy')
...@@ -271,6 +271,7 @@ class ProjectPolicy < BasePolicy ...@@ -271,6 +271,7 @@ class ProjectPolicy < BasePolicy
enable :destroy_container_image enable :destroy_container_image
enable :create_environment enable :create_environment
enable :update_environment enable :update_environment
enable :destroy_environment
enable :create_deployment enable :create_deployment
enable :update_deployment enable :update_deployment
enable :create_release enable :create_release
......
...@@ -28,6 +28,10 @@ class EnvironmentEntity < Grape::Entity ...@@ -28,6 +28,10 @@ class EnvironmentEntity < Grape::Entity
cancel_auto_stop_project_environment_path(environment.project, environment) cancel_auto_stop_project_environment_path(environment.project, environment)
end end
expose :delete_path do |environment|
environment_delete_path(environment)
end
expose :cluster_type, if: ->(environment, _) { cluster_platform_kubernetes? } do |environment| expose :cluster_type, if: ->(environment, _) { cluster_platform_kubernetes? } do |environment|
cluster.cluster_type cluster.cluster_type
end end
...@@ -63,6 +67,10 @@ class EnvironmentEntity < Grape::Entity ...@@ -63,6 +67,10 @@ class EnvironmentEntity < Grape::Entity
environment.elastic_stack_available? environment.elastic_stack_available?
end end
expose :can_delete do |environment|
can?(current_user, :destroy_environment, environment)
end
private private
alias_method :environment, :object alias_method :environment, :object
......
...@@ -5,74 +5,81 @@ ...@@ -5,74 +5,81 @@
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/xterm' = stylesheet_link_tag 'page_bundles/xterm'
- if @environment.available? && can?(current_user, :stop_environment, @environment) #environments-detail-view{ data: { name: @environment.name, id: @environment.id, delete_path: environment_delete_path(@environment)} }
#stop-environment-modal.modal.fade{ tabindex: -1 } - if @environment.available? && can?(current_user, :stop_environment, @environment)
.modal-dialog #stop-environment-modal.modal.fade{ tabindex: -1 }
.modal-content .modal-dialog
.modal-header .modal-content
%h4.modal-title.d-flex.mw-100 .modal-header
= s_("Environments|Stopping") %h4.modal-title.d-flex.mw-100
%span.has-tooltip.text-truncate.ml-1.mr-1.flex-fill{ title: @environment.name, data: { container: '#stop-environment-modal' } } = s_("Environments|Stopping")
= @environment.name %span.has-tooltip.text-truncate.ml-1.mr-1.flex-fill{ title: @environment.name, data: { container: '#stop-environment-modal' } }
? #{@environment.name}?
.modal-body .modal-body
%p= s_('Environments|Are you sure you want to stop this environment?') %p= s_('Environments|Are you sure you want to stop this environment?')
- unless @environment.stop_action_available? - unless @environment.stop_action_available?
.warning_message .warning_message
%p= s_('Environments|Note that this action will stop the environment, but it will %{emphasis_start}not%{emphasis_end} have an effect on any existing deployment due to no “stop environment action” being defined in the %{ci_config_link_start}.gitlab-ci.yml%{ci_config_link_end} file.').html_safe % { emphasis_start: '<strong>'.html_safe, %p= s_('Environments|Note that this action will stop the environment, but it will %{emphasis_start}not%{emphasis_end} have an effect on any existing deployment due to no “stop environment action” being defined in the %{ci_config_link_start}.gitlab-ci.yml%{ci_config_link_end} file.').html_safe % { emphasis_start: '<strong>'.html_safe,
emphasis_end: '</strong>'.html_safe, emphasis_end: '</strong>'.html_safe,
ci_config_link_start: '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">'.html_safe, ci_config_link_start: '<a href="https://docs.gitlab.com/ee/ci/yaml/" target="_blank" rel="noopener noreferrer">'.html_safe,
ci_config_link_end: '</a>'.html_safe } ci_config_link_end: '</a>'.html_safe }
%a{ href: 'https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment', %a{ href: 'https://docs.gitlab.com/ee/ci/environments.html#stopping-an-environment',
target: '_blank', target: '_blank',
rel: 'noopener noreferrer' } rel: 'noopener noreferrer' }
= s_('Environments|Learn more about stopping environments') = s_('Environments|Learn more about stopping environments')
.modal-footer .modal-footer
= button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' } = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' }
= button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do = button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do
= s_('Environments|Stop environment') = s_('Environments|Stop environment')
.top-area.justify-content-between - if can_destroy_environment?(@environment)
.d-flex #delete-environment-modal
%h3.page-title= @environment.name
- if @environment.auto_stop_at?
%p.align-self-end.prepend-left-8
= s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)}
.nav-controls.my-2
= render 'projects/environments/pin_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment
= render 'projects/environments/metrics_button', environment: @environment
- if can?(current_user, :update_environment, @environment)
= link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn'
- if @environment.available? && can?(current_user, :stop_environment, @environment)
= button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
target: '#stop-environment-modal' } do
= sprite_icon('stop')
= s_('Environments|Stop')
.environments-container .top-area.justify-content-between
- if @deployments.blank? .d-flex
.empty-state %h3.page-title= @environment.name
.text-content - if @environment.auto_stop_at?
%h4.state-title %p.align-self-end.prepend-left-8
= _("You don't have any deployments right now.") = s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)}
%p.blank-state-text .nav-controls.my-2
= _("Define environments in the deploy stage(s) in <code>.gitlab-ci.yml</code> to track deployments here.").html_safe = render 'projects/environments/pin_button', environment: @environment
.text-center = render 'projects/environments/terminal_button', environment: @environment
= link_to _("Read more"), help_page_path("ci/environments"), class: "btn btn-success" = render 'projects/environments/external_url', environment: @environment
- else = render 'projects/environments/metrics_button', environment: @environment
.table-holder - if can?(current_user, :update_environment, @environment)
.ci-table.environments{ role: 'grid' } = link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn'
.gl-responsive-table-row.table-row-header{ role: 'row' } - if @environment.available? && can?(current_user, :stop_environment, @environment)
.table-section.section-15{ role: 'columnheader' }= _('Status') = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
.table-section.section-10{ role: 'columnheader' }= _('ID') target: '#stop-environment-modal' } do
.table-section.section-10{ role: 'columnheader' }= _('Triggerer') = sprite_icon('stop')
.table-section.section-25{ role: 'columnheader' }= _('Commit') = s_('Environments|Stop')
.table-section.section-10{ role: 'columnheader' }= _('Job') - if can_destroy_environment?(@environment)
.table-section.section-10{ role: 'columnheader' }= _('Created') = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
.table-section.section-10{ role: 'columnheader' }= _('Deployed') target: '#delete-environment-modal' } do
= s_('Environments|Delete')
= render @deployments .environments-container
- if @deployments.blank?
.empty-state
.text-content
%h4.state-title
= _("You don't have any deployments right now.")
%p.blank-state-text
= _("Define environments in the deploy stage(s) in <code>.gitlab-ci.yml</code> to track deployments here.").html_safe
.text-center
= link_to _("Read more"), help_page_path("ci/environments"), class: "btn btn-success"
- else
.table-holder
.ci-table.environments{ role: 'grid' }
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-15{ role: 'columnheader' }= _('Status')
.table-section.section-10{ role: 'columnheader' }= _('ID')
.table-section.section-10{ role: 'columnheader' }= _('Triggerer')
.table-section.section-25{ role: 'columnheader' }= _('Commit')
.table-section.section-10{ role: 'columnheader' }= _('Job')
.table-section.section-10{ role: 'columnheader' }= _('Created')
.table-section.section-10{ role: 'columnheader' }= _('Deployed')
= paginate @deployments, theme: 'gitlab' = render @deployments
= paginate @deployments, theme: 'gitlab'
---
title: Adds features to delete stopped environments
merge_request: 22629
author:
type: added
...@@ -761,6 +761,33 @@ runs once every hour. This means environments will not be stopped at the exact ...@@ -761,6 +761,33 @@ runs once every hour. This means environments will not be stopped at the exact
timestamp as the specified period, but will be stopped when the hourly cron worker timestamp as the specified period, but will be stopped when the hourly cron worker
detects expired environments. detects expired environments.
#### Delete a stopped environment
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22629) in GitLab 12.9.
You can delete [stopped environments](#stopping-an-environment) in one of two
ways: through the GitLab UI or through the API.
##### Delete environments through the UI
To view the list of **Stopped** environments, navigate to **Operations > Environments**
and click the **Stopped** tab.
From there, you can click the **Delete** button directly, or you can click the
environment name to see its details and **Delete** it from there.
You can also delete environments by viewing the details for a
stopped environment:
1. Navigate to **Operations > Environments**.
1. Click on the name of an environment within the **Stopped** environments list.
1. Click on the **Delete** button that appears at the top for all stopped environments.
1. Finally, confirm your chosen environment in the modal that appears to delete it.
##### Delete environments through the API
Environments can also be deleted by using the [Environments API](../api/environments.md#delete-an-environment).
### Grouping similar environments ### Grouping similar environments
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/7015) in GitLab 8.14. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/7015) in GitLab 8.14.
......
...@@ -4,7 +4,6 @@ module EE ...@@ -4,7 +4,6 @@ module EE
module GitlabRoutingHelper module GitlabRoutingHelper
include ::ProjectsHelper include ::ProjectsHelper
include ::ApplicationSettingsHelper include ::ApplicationSettingsHelper
include ::API::Helpers::RelatedResourcesHelpers
def geo_primary_web_url(project_or_wiki) def geo_primary_web_url(project_or_wiki)
File.join(::Gitlab::Geo.primary_node.url, project_or_wiki.full_path) File.join(::Gitlab::Geo.primary_node.url, project_or_wiki.full_path)
......
...@@ -12,6 +12,7 @@ module EE ...@@ -12,6 +12,7 @@ module EE
prevent :create_deployment prevent :create_deployment
prevent :update_deployment prevent :update_deployment
prevent :update_environment prevent :update_environment
prevent :destroy_environment
end end
private private
......
...@@ -76,6 +76,12 @@ ...@@ -76,6 +76,12 @@
"type": "boolean" "type": "boolean"
}, },
"cancel_auto_stop_path": { "type": "string" }, "cancel_auto_stop_path": { "type": "string" },
"auto_stop_at": { "type": "string", "format": "date-time" } "auto_stop_at": { "type": "string", "format": "date-time" },
"can_delete": {
"type": "boolean"
},
"delete_path": {
"type": "string"
}
} }
} }
...@@ -18,6 +18,16 @@ describe EnvironmentPolicy do ...@@ -18,6 +18,16 @@ describe EnvironmentPolicy do
it_behaves_like 'protected environments access' it_behaves_like 'protected environments access'
end end
describe '#destroy_environment' do
subject { user.can?(:destroy_environment, environment) }
before do
environment.stop!
end
it_behaves_like 'protected environments access'
end
describe '#create_environment_terminal' do describe '#create_environment_terminal' do
subject { user.can?(:create_environment_terminal, environment) } subject { user.can?(:create_environment_terminal, environment) }
......
...@@ -82,9 +82,10 @@ module API ...@@ -82,9 +82,10 @@ module API
requires :environment_id, type: Integer, desc: 'The environment ID' requires :environment_id, type: Integer, desc: 'The environment ID'
end end
delete ':id/environments/:environment_id' do delete ':id/environments/:environment_id' do
authorize! :update_environment, user_project authorize! :read_environment, user_project
environment = user_project.environments.find(params[:environment_id]) environment = user_project.environments.find(params[:environment_id])
authorize! :destroy_environment, environment
destroy_conditionally!(environment) destroy_conditionally!(environment)
end end
......
...@@ -7695,6 +7695,9 @@ msgstr "" ...@@ -7695,6 +7695,9 @@ msgstr ""
msgid "Environments|An error occurred while canceling the auto stop, please try again" msgid "Environments|An error occurred while canceling the auto stop, please try again"
msgstr "" msgstr ""
msgid "Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again."
msgstr ""
msgid "Environments|An error occurred while fetching the environments." msgid "Environments|An error occurred while fetching the environments."
msgstr "" msgstr ""
...@@ -7728,6 +7731,15 @@ msgstr "" ...@@ -7728,6 +7731,15 @@ msgstr ""
msgid "Environments|Currently showing all results." msgid "Environments|Currently showing all results."
msgstr "" msgstr ""
msgid "Environments|Delete"
msgstr ""
msgid "Environments|Delete environment"
msgstr ""
msgid "Environments|Deleting the '%{environmentName}' environment cannot be undone. Do you want to delete it anyway?"
msgstr ""
msgid "Environments|Deploy to..." msgid "Environments|Deploy to..."
msgstr "" msgstr ""
......
...@@ -44,7 +44,10 @@ ...@@ -44,7 +44,10 @@
"build_path": { "type": "string" } "build_path": { "type": "string" }
} }
] ]
} },
"can_delete": { "type": "boolean" }
,
"delete_path": { "type": "string" }
}, },
"additionalProperties": false "additionalProperties": false
} }
import $ from 'jquery';
import { shallowMount } from '@vue/test-utils';
import DeleteComponent from '~/environments/components/environment_delete.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '~/environments/event_hub';
$.fn.tooltip = () => {};
describe('External URL Component', () => {
let wrapper;
const createWrapper = () => {
wrapper = shallowMount(DeleteComponent, {
propsData: {
environment: {},
},
});
};
const findButton = () => wrapper.find(LoadingButton);
beforeEach(() => {
jest.spyOn(window, 'confirm');
createWrapper();
});
it('should render a button to delete the environment', () => {
expect(findButton().exists()).toBe(true);
expect(wrapper.attributes('title')).toEqual('Delete environment');
});
it('emits requestDeleteEnvironment in the event hub when button is clicked', () => {
jest.spyOn(eventHub, '$emit');
findButton().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', wrapper.vm.environment);
});
});
...@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import { format } from 'timeago.js'; import { format } from 'timeago.js';
import EnvironmentItem from '~/environments/components/environment_item.vue'; import EnvironmentItem from '~/environments/components/environment_item.vue';
import PinComponent from '~/environments/components/environment_pin.vue'; import PinComponent from '~/environments/components/environment_pin.vue';
import DeleteComponent from '~/environments/components/environment_delete.vue';
import { environment, folder, tableData } from './mock_data'; import { environment, folder, tableData } from './mock_data';
...@@ -54,6 +55,10 @@ describe('Environment item', () => { ...@@ -54,6 +55,10 @@ describe('Environment item', () => {
expect(wrapper.find('.environment-created-date-timeago').text()).toContain(formattedDate); expect(wrapper.find('.environment-created-date-timeago').text()).toContain(formattedDate);
}); });
it('should not render the delete button', () => {
expect(wrapper.find(DeleteComponent).exists()).toBe(false);
});
describe('With user information', () => { describe('With user information', () => {
it('should render user avatar with link to profile', () => { it('should render user avatar with link to profile', () => {
expect(wrapper.find('.js-deploy-user-container').attributes('href')).toEqual( expect(wrapper.find('.js-deploy-user-container').attributes('href')).toEqual(
...@@ -98,7 +103,7 @@ describe('Environment item', () => { ...@@ -98,7 +103,7 @@ describe('Environment item', () => {
expect(findAutoStop().exists()).toBe(false); expect(findAutoStop().exists()).toBe(false);
}); });
it('should not render the suto-stop button', () => { it('should not render the auto-stop button', () => {
expect(wrapper.find(PinComponent).exists()).toBe(false); expect(wrapper.find(PinComponent).exists()).toBe(false);
}); });
}); });
...@@ -205,4 +210,22 @@ describe('Environment item', () => { ...@@ -205,4 +210,22 @@ describe('Environment item', () => {
expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size); expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size);
}); });
}); });
describe('When environment can be deleted', () => {
beforeEach(() => {
factory({
propsData: {
model: {
can_delete: true,
delete_path: 'http://0.0.0.0:3000/api/v4/projects/8/environments/45',
},
tableData,
},
});
});
it('should render the delete button', () => {
expect(wrapper.find(DeleteComponent).exists()).toBe(true);
});
});
}); });
...@@ -86,6 +86,50 @@ describe EnvironmentPolicy do ...@@ -86,6 +86,50 @@ describe EnvironmentPolicy do
it { expect(policy).to be_allowed :stop_environment } it { expect(policy).to be_allowed :stop_environment }
end end
end end
describe '#destroy_environment' do
let(:environment) do
create(:environment, project: project)
end
where(:access_level, :allowed?) do
nil | false
:guest | false
:reporter | false
:developer | true
:maintainer | true
end
with_them do
before do
project.add_user(user, access_level) unless access_level.nil?
end
it { expect(policy).to be_disallowed :destroy_environment }
context 'when environment is stopped' do
before do
environment.stop!
end
it { expect(policy.allowed?(:destroy_environment)).to be allowed? }
end
end
context 'when an admin user' do
let(:user) { create(:user, :admin) }
it { expect(policy).to be_disallowed :destroy_environment }
context 'when environment is stopped' do
before do
environment.stop!
end
it { expect(policy).to be_allowed :destroy_environment }
end
end
end
end end
context 'when project is public' do context 'when project is public' do
......
...@@ -171,7 +171,15 @@ describe API::Environments do ...@@ -171,7 +171,15 @@ describe API::Environments do
describe 'DELETE /projects/:id/environments/:environment_id' do describe 'DELETE /projects/:id/environments/:environment_id' do
context 'as a maintainer' do context 'as a maintainer' do
it 'returns a 200 for an existing environment' do it "rejects the requests in environment isn't stopped" do
delete api("/projects/#{project.id}/environments/#{environment.id}", user)
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'returns a 200 for stopped environment' do
environment.stop
delete api("/projects/#{project.id}/environments/#{environment.id}", user) delete api("/projects/#{project.id}/environments/#{environment.id}", user)
expect(response).to have_gitlab_http_status(:no_content) expect(response).to have_gitlab_http_status(:no_content)
...@@ -185,6 +193,10 @@ describe API::Environments do ...@@ -185,6 +193,10 @@ describe API::Environments do
end end
it_behaves_like '412 response' do it_behaves_like '412 response' do
before do
environment.stop
end
let(:request) { api("/projects/#{project.id}/environments/#{environment.id}", user) } let(:request) { api("/projects/#{project.id}/environments/#{environment.id}", user) }
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment