Commit 72c6ac9f authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '211339-forward-deployment-warn-users-on-retry_followup' into 'master'

Forward deployment, add modal to warn users on "Retry" action

See merge request gitlab-org/gitlab!46416
parents 2b88a945 5192ac4b
<script>
import { GlLink, GlModal } from '@gitlab/ui';
import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '../constants';
export default {
name: 'JobRetryForwardDeploymentModal',
components: {
GlLink,
GlModal,
},
i18n: {
...JOB_RETRY_FORWARD_DEPLOYMENT_MODAL,
},
props: {
modalId: {
type: String,
required: true,
},
href: {
type: String,
required: true,
},
},
inject: {
retryOutdatedJobDocsUrl: {
default: '',
},
},
data() {
return {
primaryProps: {
text: this.$options.i18n.primaryText,
attributes: [
{
'data-method': 'post',
'data-testid': 'retry-button-modal',
href: this.href,
variant: 'danger',
},
],
},
cancelProps: {
text: this.$options.i18n.cancel,
attributes: [{ category: 'secondary', variant: 'default' }],
},
};
},
};
</script>
<template>
<gl-modal
:action-cancel="cancelProps"
:action-primary="primaryProps"
:modal-id="modalId"
:title="$options.i18n.title"
>
<p>
{{ $options.i18n.info }}
<gl-link v-if="retryOutdatedJobDocsUrl" :href="retryOutdatedJobDocsUrl" target="_blank">
{{ $options.i18n.moreInfo }}
</gl-link>
</p>
<p>{{ $options.i18n.areYouSure }}</p>
</gl-modal>
</template>
<script>
import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { JOB_SIDEBAR } from '../constants';
export default {
name: 'JobSidebarRetryButton',
i18n: {
retryLabel: JOB_SIDEBAR.retry,
},
components: {
GlButton,
GlLink,
},
directives: {
GlModal: GlModalDirective,
},
props: {
modalId: {
type: String,
required: true,
},
href: {
type: String,
required: true,
},
},
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
},
};
</script>
<template>
<gl-button
v-if="hasForwardDeploymentFailure"
v-gl-modal="modalId"
:aria-label="$options.i18n.retryLabel"
category="primary"
variant="info"
>{{ $options.i18n.retryLabel }}</gl-button
>
<gl-link v-else :href="href" data-method="post" rel="nofollow"
>{{ $options.i18n.retryLabel }}
</gl-link>
</template>
......@@ -24,7 +24,7 @@ export default {
};
</script>
<template>
<div class="js-jobs-container builds-container">
<div class="builds-container">
<job-container-item
v-for="job in jobs"
:key="job.id"
......
<script>
import { isEmpty } from 'lodash';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import ArtifactsBlock from './artifacts_block.vue';
import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue';
import TriggerBlock from './trigger_block.vue';
import CommitBlock from './commit_block.vue';
import StagesDropdown from './stages_dropdown.vue';
import JobsContainer from './jobs_container.vue';
import SidebarJobDetailsContainer from './sidebar_job_details_container.vue';
import JobSidebarDetailsContainer from './sidebar_job_details_container.vue';
import { JOB_SIDEBAR } from '../constants';
export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
export default {
name: 'JobSidebar',
i18n: {
...JOB_SIDEBAR,
},
forwardDeploymentFailureModalId,
components: {
ArtifactsBlock,
CommitBlock,
GlButton,
GlLink,
GlIcon,
TriggerBlock,
StagesDropdown,
JobsContainer,
GlLink,
GlButton,
SidebarJobDetailsContainer,
JobSidebarRetryButton,
JobRetryForwardDeploymentModal,
JobSidebarDetailsContainer,
StagesDropdown,
TooltipOnTruncate,
TriggerBlock,
},
props: {
artifactHelpUrl: {
......@@ -37,9 +48,10 @@ export default {
},
},
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
retryButtonClass() {
let className = 'js-retry-button btn btn-retry';
let className = 'btn btn-retry';
className +=
this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary';
return className;
......@@ -56,6 +68,9 @@ export default {
commit() {
return this.job?.pipeline?.commit || {};
},
shouldShowJobRetryForwardDeploymentModal() {
return this.job.retry_path && this.hasForwardDeploymentFailure;
},
},
methods: {
...mapActions(['fetchJobsForStage', 'toggleSidebar']),
......@@ -73,27 +88,27 @@ export default {
</h4>
</tooltip-on-truncate>
<div class="flex-grow-1 flex-shrink-0 text-right">
<gl-link
<job-sidebar-retry-button
v-if="job.retry_path"
:class="retryButtonClass"
:href="job.retry_path"
data-method="post"
:modal-id="$options.forwardDeploymentFailureModalId"
data-qa-selector="retry_button"
rel="nofollow"
>{{ __('Retry') }}
</gl-link>
data-testid="retry-button"
/>
<gl-link
v-if="job.cancel_path"
:href="job.cancel_path"
class="js-cancel-job btn btn-default"
class="btn btn-default"
data-method="post"
data-testid="cancel-button"
rel="nofollow"
>{{ __('Cancel') }}
>{{ $options.i18n.cancel }}
</gl-link>
</div>
<gl-button
:aria-label="__('Toggle Sidebar')"
:aria-label="$options.i18n.toggleSidebar"
category="tertiary"
class="gl-display-md-none gl-ml-2 js-sidebar-build-toggle"
icon="chevron-double-lg-right"
......@@ -107,19 +122,20 @@ export default {
:href="job.new_issue_path"
class="btn btn-success btn-inverted float-left mr-2"
data-testid="job-new-issue"
>{{ __('New issue') }}
>{{ $options.i18n.newIssue }}
</gl-link>
<gl-link
v-if="job.terminal_path"
:href="job.terminal_path"
class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
class="btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
target="_blank"
data-testid="terminal-link"
>
{{ __('Debug') }}
{{ $options.i18n.debug }}
<gl-icon :size="14" name="external-link" />
</gl-link>
</div>
<sidebar-job-details-container :runner-help-url="runnerHelpUrl" />
<job-sidebar-details-container :runner-help-url="runnerHelpUrl" />
<artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" />
<trigger-block v-if="hasTriggers" :trigger="job.trigger" />
<commit-block
......@@ -139,5 +155,10 @@ export default {
<jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" />
</div>
<job-retry-forward-deployment-modal
v-if="shouldShowJobRetryForwardDeploymentModal"
:modal-id="$options.forwardDeploymentFailureModalId"
:href="job.retry_path"
/>
</aside>
</template>
......@@ -6,7 +6,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
export default {
name: 'SidebarJobDetailsContainer',
name: 'JobSidebarDetailsContainer',
components: {
DetailRow,
},
......
import { __, s__ } from '~/locale';
const cancel = __('Cancel');
const moreInfo = __('More information');
export const JOB_SIDEBAR = {
cancel,
debug: __('Debug'),
newIssue: __('New issue'),
retry: __('Retry'),
toggleSidebar: __('Toggle Sidebar'),
};
export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
cancel,
info: s__(
`Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment.
Retrying this job could result in overwriting the environment with the older source code.`,
),
areYouSure: s__('Jobs|Are you sure you want to proceed?'),
moreInfo,
primaryText: __('Retry job'),
title: s__('Jobs|Are you sure you want to retry this job?'),
};
......@@ -10,27 +10,31 @@ export default () => {
// Let's start initializing the store (i.e. fetching data) right away
store.dispatch('init', element.dataset);
const {
artifactHelpUrl,
deploymentHelpUrl,
runnerHelpUrl,
runnerSettingsUrl,
variablesSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
pagePath,
logState,
buildStatus,
projectPath,
retryOutdatedJobDocsUrl,
} = element.dataset;
return new Vue({
el: element,
store,
components: {
JobApp,
},
provide: {
retryOutdatedJobDocsUrl,
},
render(createElement) {
const {
artifactHelpUrl,
deploymentHelpUrl,
runnerHelpUrl,
runnerSettingsUrl,
variablesSettingsUrl,
subscriptionsMoreMinutesUrl,
endpoint,
pagePath,
logState,
buildStatus,
projectPath,
} = element.dataset;
return createElement('job-app', {
props: {
artifactHelpUrl,
......
......@@ -3,6 +3,9 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
export const hasForwardDeploymentFailure = state =>
state?.job?.failure_reason === 'forward_deployment_failure';
export const hasUnmetPrerequisitesFailure = state =>
state?.job?.failure_reason === 'unmet_prerequisites';
......
......@@ -15,7 +15,8 @@ module Ci
"build_status" => @build.status,
"build_stage" => @build.stage,
"log_state" => '',
"build_options" => javascript_build_options
"build_options" => javascript_build_options,
"retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs')
}
end
end
......
---
title: Forward deployment, add modal to warn users on Retry action
merge_request: 46416
author:
type: added
......@@ -231,6 +231,16 @@ When enabled, any older deployments job are skipped when a new deployment starts
For more information, see [Deployment safety](../environments/deployment_safety.md).
## Retry outdated jobs
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211339) in GitLab 13.6.
A deployment job can fail because a newer one has run. If you retry the failed deployment job, the
environment could be overwritten with older source code. If you click **Retry**, a modal warns you
about this and asks for confirmation.
For more information, see [Deployment safety](../environments/deployment_safety.md).
## Pipeline Badges
In the pipelines settings page you can find pipeline status and test coverage
......
......@@ -15334,6 +15334,15 @@ msgstr ""
msgid "Jobs"
msgstr ""
msgid "Jobs|Are you sure you want to proceed?"
msgstr ""
msgid "Jobs|Are you sure you want to retry this job?"
msgstr ""
msgid "Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. Retrying this job could result in overwriting the environment with the older source code."
msgstr ""
msgid "Job|Browse"
msgstr ""
......@@ -23088,6 +23097,9 @@ msgstr ""
msgid "Retry"
msgstr ""
msgid "Retry job"
msgstr ""
msgid "Retry this job"
msgstr ""
......
......@@ -504,6 +504,11 @@ FactoryBot.define do
failure_reason { 10 }
end
trait :forward_deployment_failure do
failed
failure_reason { 13 }
end
trait :with_runner_session do
after(:build) do |build|
build.build_runner_session(url: 'https://localhost')
......
......@@ -1013,7 +1013,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
before do
job.run!
visit project_job_path(project, job)
find('.js-cancel-job').click
find('[data-testid="cancel-button"]').click
end
it 'loads the page and shows all needed controls' do
......@@ -1030,7 +1030,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
visit project_job_path(project, job)
wait_for_requests
find('.js-retry-button').click
find('[data-testid="retry-button"]').click
end
it 'shows the right status and buttons' do
......@@ -1057,6 +1057,31 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
end
end
end
context "Job that failed because of a forward deployment failure" do
let(:job) { create(:ci_build, :forward_deployment_failure, pipeline: pipeline) }
before do
visit project_job_path(project, job)
wait_for_requests
find('[data-testid="retry-button"]').click
end
it 'shows a modal to warn the user' do
page.within('.modal-header') do
expect(page).to have_content 'Are you sure you want to retry this job?'
end
end
it 'retries the job' do
find('[data-testid="retry-button-modal"]').click
within '[data-testid="ci-header-content"]' do
expect(page).to have_content('pending')
end
end
end
end
describe "GET /:project/jobs/:id/download", :js do
......
......@@ -129,7 +129,7 @@ describe('Configure Feature Flags Modal', () => {
expect(findPrimaryAction()).toBe(null);
});
it('shold not display regenerating instance ID', async () => {
it('should not display regenerating instance ID', async () => {
expect(findDangerCallout().exists()).toBe(false);
});
......
import { GlLink, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue';
import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants';
import createStore from '~/jobs/store';
import job from '../mock_data';
describe('Job Retry Forward Deployment Modal', () => {
let store;
let wrapper;
const retryOutdatedJobDocsUrl = 'url-to-docs';
const findLink = () => wrapper.find(GlLink);
const findModal = () => wrapper.find(GlModal);
const createWrapper = ({ props = {}, provide = {}, stubs = {} } = {}) => {
store = createStore();
wrapper = shallowMount(JobRetryForwardDeploymentModal, {
propsData: {
modalId: 'modal-id',
href: job.retry_path,
...props,
},
provide,
store,
stubs,
});
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
beforeEach(createWrapper);
describe('Modal configuration', () => {
it('should display the correct messages', () => {
const modal = findModal();
expect(modal.attributes('title')).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.title);
expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.info);
expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.areYouSure);
});
});
describe('Modal docs help link', () => {
it('should not display an info link when none is provided', () => {
createWrapper();
expect(findLink().exists()).toBe(false);
});
it('should display an info link when one is provided', () => {
createWrapper({ provide: { retryOutdatedJobDocsUrl } });
expect(findLink().attributes('href')).toBe(retryOutdatedJobDocsUrl);
expect(findLink().text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.moreInfo);
});
});
describe('Modal actions', () => {
beforeEach(createWrapper);
it('should correctly configure the primary action', () => {
expect(findModal().props('actionPrimary').attributes).toMatchObject([
{
'data-method': 'post',
href: job.retry_path,
variant: 'danger',
},
]);
});
});
});
......@@ -5,7 +5,7 @@ import createStore from '~/jobs/store';
import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
import job from '../mock_data';
describe('Sidebar Job Details Container', () => {
describe('Job Sidebar Details Container', () => {
let store;
let wrapper;
......
import { GlButton, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import job from '../mock_data';
import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
import createStore from '~/jobs/store';
describe('Job Sidebar Retry Button', () => {
let store;
let wrapper;
const forwardDeploymentFailure = 'forward_deployment_failure';
const findRetryButton = () => wrapper.find(GlButton);
const findRetryLink = () => wrapper.find(GlLink);
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
wrapper = shallowMount(JobsSidebarRetryButton, {
propsData: {
href: job.retry_path,
modalId: 'modal-id',
...props,
},
store,
});
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
beforeEach(createWrapper);
it.each([
[null, false, true],
['unmet_prerequisites', false, true],
[forwardDeploymentFailure, true, false],
])(
'when error is: %s, should render button: %s | should render link: %s',
async (failureReason, buttonExists, linkExists) => {
await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason });
expect(findRetryButton().exists()).toBe(buttonExists);
expect(findRetryLink().exists()).toBe(linkExists);
expect(wrapper.text()).toMatch('Retry');
},
);
describe('Button', () => {
it('should have the correct configuration', async () => {
await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure });
expect(findRetryButton().attributes()).toMatchObject({
category: 'primary',
variant: 'info',
});
});
});
describe('Link', () => {
it('should have the correct configuration', () => {
expect(findRetryLink().attributes()).toMatchObject({
'data-method': 'post',
href: job.retry_path,
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import Sidebar from '~/jobs/components/sidebar.vue';
import Sidebar, { forwardDeploymentFailureModalId } from '~/jobs/components/sidebar.vue';
import StagesDropdown from '~/jobs/components/stages_dropdown.vue';
import JobsContainer from '~/jobs/components/jobs_container.vue';
import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue';
import JobRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
import createStore from '~/jobs/store';
import job, { jobsInStage } from '../mock_data';
import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
......@@ -10,6 +12,13 @@ describe('Sidebar details block', () => {
let store;
let wrapper;
const forwardDeploymentFailure = 'forward_deployment_failure';
const findModal = () => wrapper.find(JobRetryForwardDeploymentModal);
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
const findRetryButton = () => wrapper.find(JobRetryButton);
const findTerminalLink = () => wrapper.findByTestId('terminal-link');
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
wrapper = extendedWrapper(
......@@ -33,7 +42,7 @@ describe('Sidebar details block', () => {
const copy = { ...job, retry_path: null };
await store.dispatch('receiveJobSuccess', copy);
expect(wrapper.find('.js-retry-button').exists()).toBe(false);
expect(findRetryButton().exists()).toBe(false);
});
});
......@@ -42,7 +51,7 @@ describe('Sidebar details block', () => {
createWrapper();
await store.dispatch('receiveJobSuccess', job);
expect(wrapper.find('.js-terminal-link').exists()).toBe(false);
expect(findTerminalLink().exists()).toBe(false);
});
});
......@@ -51,7 +60,7 @@ describe('Sidebar details block', () => {
createWrapper();
await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
expect(wrapper.find('.js-terminal-link').exists()).toBe(true);
expect(findTerminalLink().exists()).toBe(true);
});
});
......@@ -62,16 +71,65 @@ describe('Sidebar details block', () => {
});
it('should render link to new issue', () => {
expect(wrapper.findByTestId('job-new-issue').attributes('href')).toBe(job.new_issue_path);
expect(wrapper.find('[data-testid="job-new-issue"]').text()).toBe('New issue');
expect(findNewIssueButton().attributes('href')).toBe(job.new_issue_path);
expect(findNewIssueButton().text()).toBe('New issue');
});
it('should render link to retry job', () => {
expect(wrapper.find('.js-retry-button').attributes('href')).toBe(job.retry_path);
it('should render the retry button', () => {
expect(findRetryButton().props('href')).toBe(job.retry_path);
});
it('should render link to cancel job', () => {
expect(wrapper.find('.js-cancel-job').attributes('href')).toBe(job.cancel_path);
expect(findCancelButton().text()).toMatch('Cancel');
expect(findCancelButton().attributes('href')).toBe(job.cancel_path);
});
});
describe('forward deployment failure', () => {
describe('when the relevant data is missing', () => {
it.each`
retryPath | failureReason
${null} | ${null}
${''} | ${''}
${job.retry_path} | ${''}
${''} | ${forwardDeploymentFailure}
${job.retry_path} | ${'unmet_prerequisites'}
`(
'should not render the modal when path and failure are $retryPath, $failureReason',
async ({ retryPath, failureReason }) => {
createWrapper();
await store.dispatch('receiveJobSuccess', {
...job,
failure_reason: failureReason,
retry_path: retryPath,
});
expect(findModal().exists()).toBe(false);
},
);
});
describe('when there is the relevant error', () => {
beforeEach(() => {
createWrapper();
return store.dispatch('receiveJobSuccess', {
...job,
failure_reason: forwardDeploymentFailure,
});
});
it('should render the modal', () => {
expect(findModal().exists()).toBe(true);
});
it('should provide the modal id to the button and modal', () => {
expect(findRetryButton().props('modalId')).toBe(forwardDeploymentFailureModalId);
expect(findModal().props('modalId')).toBe(forwardDeploymentFailureModalId);
});
it('should provide the retry path to the button and modal', () => {
expect(findRetryButton().props('href')).toBe(job.retry_path);
expect(findModal().props('href')).toBe(job.retry_path);
});
});
});
......@@ -90,8 +148,8 @@ describe('Sidebar details block', () => {
describe('without jobs for stages', () => {
beforeEach(() => store.dispatch('receiveJobSuccess', job));
it('does not render job container', () => {
expect(wrapper.find('.js-jobs-container').exists()).toBe(false);
it('does not render jobs container', () => {
expect(wrapper.find(JobsContainer).exists()).toBe(false);
});
});
......
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