diff options
author | Kamil Trzciński <ayufan@ayufan.eu> | 2018-10-05 16:30:33 +0000 |
---|---|---|
committer | Kamil Trzciński <ayufan@ayufan.eu> | 2018-10-05 16:30:33 +0000 |
commit | 059da9bc8eb9355a760031ef8e73b0aa6285012f (patch) | |
tree | b6057c99d0c53951a650122d624dc37405194551 /spec | |
parent | 7f86172f806558d2b614abcb06cef0ea516c5900 (diff) | |
parent | 7542a5d102bc48f5f7b8104fda22f0975b2dd931 (diff) | |
download | gitlab-ce-059da9bc8eb9355a760031ef8e73b0aa6285012f.tar.gz |
Merge branch 'scheduled-manual-jobs' into 'master'
Delayed jobs
Closes #51352
See merge request gitlab-org/gitlab-ce!21767
Diffstat (limited to 'spec')
38 files changed, 1623 insertions, 100 deletions
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index 30a418c0e88..383d6c1a2a9 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -631,6 +631,46 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do end end + describe 'POST unschedule' do + before do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) + + sign_in(user) + + post_unschedule + end + + context 'when job is scheduled' do + let(:job) { create(:ci_build, :scheduled, pipeline: pipeline) } + + it 'redirects to the unscheduled job page' do + expect(response).to have_gitlab_http_status(:found) + expect(response).to redirect_to(namespace_project_job_path(id: job.id)) + end + + it 'transits to manual' do + expect(job.reload).to be_manual + end + end + + context 'when job is not scheduled' do + let(:job) { create(:ci_build, pipeline: pipeline) } + + it 'renders unprocessable_entity' do + expect(response).to have_gitlab_http_status(:unprocessable_entity) + end + end + + def post_unschedule + post :unschedule, namespace_id: project.namespace, + project_id: project, + id: job.id + end + end + describe 'POST cancel_all' do before do project.add_developer(user) diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 0baa4ecc4e0..85ba7d4097d 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -70,6 +70,18 @@ FactoryBot.define do status 'created' end + trait :scheduled do + schedulable + status 'scheduled' + scheduled_at { 1.minute.since } + end + + trait :expired_scheduled do + schedulable + status 'scheduled' + scheduled_at { 1.minute.ago } + end + trait :manual do status 'manual' self.when 'manual' @@ -98,6 +110,15 @@ FactoryBot.define do success end + trait :schedulable do + self.when 'delayed' + options start_in: '1 minute' + end + + trait :actionable do + self.when 'manual' + end + trait :retried do retried true end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 9fef424e425..8a44ce52849 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -54,6 +54,10 @@ FactoryBot.define do status :manual end + trait :scheduled do + status :scheduled + end + trait :success do status :success end diff --git a/spec/factories/commit_statuses.rb b/spec/factories/commit_statuses.rb index 53368c64e10..381bf07f6a0 100644 --- a/spec/factories/commit_statuses.rb +++ b/spec/factories/commit_statuses.rb @@ -41,6 +41,10 @@ FactoryBot.define do status 'manual' end + trait :scheduled do + status 'scheduled' + end + after(:build) do |build, evaluator| build.project = build.pipeline.project end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 9fe56d840e1..67b4a520184 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -559,6 +559,34 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do end end + context 'Delayed job' do + let(:job) { create(:ci_build, :scheduled, pipeline: pipeline) } + + before do + project.add_developer(user) + visit project_job_path(project, job) + end + + it 'shows delayed job', :js do + time_diff = [0, job.scheduled_at - Time.now].max + + expect(page).to have_content(job.detailed_status(user).illustration[:title]) + expect(page).to have_content('This is a scheduled to run in') + expect(page).to have_content("This job will automatically run after it's timer finishes.") + expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M:%S")) + expect(page).to have_link('Unschedule job') + end + + it 'unschedules delayed job and shows manual action', :js do + click_link 'Unschedule job' + + wait_for_requests + expect(page).to have_content('This job requires a manual action') + expect(page).to have_content('This job depends on a user to trigger its process. Often they are used to deploy code to production environments') + expect(page).to have_link('Trigger this manual action') + end + end + context 'Non triggered job' do let(:job) { create(:ci_build, :created, pipeline: pipeline) } diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 603503a531c..491c64fc329 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -31,6 +31,11 @@ describe 'Pipeline', :js do pipeline: pipeline, stage: 'deploy', name: 'manual-build') end + let!(:build_scheduled) do + create(:ci_build, :scheduled, + pipeline: pipeline, stage: 'deploy', name: 'delayed-job') + end + let!(:build_external) do create(:generic_commit_status, status: 'success', pipeline: pipeline, @@ -79,10 +84,12 @@ describe 'Pipeline', :js do end end - it 'should be possible to cancel the running build' do + it 'cancels the running build and shows retry button' do find('#ci-badge-deploy .ci-action-icon-container').click - expect(page).not_to have_content('Cancel running') + page.within('#ci-badge-deploy') do + expect(page).to have_css('.js-icon-retry') + end end end @@ -105,6 +112,27 @@ describe 'Pipeline', :js do end end + context 'when pipeline has a delayed job' do + it 'shows the scheduled icon and an unschedule action for the delayed job' do + page.within('#ci-badge-delayed-job') do + expect(page).to have_selector('.js-ci-status-icon-scheduled') + expect(page).to have_content('delayed-job') + end + + page.within('#ci-badge-delayed-job .ci-action-icon-container.js-icon-time-out') do + expect(page).to have_selector('svg') + end + end + + it 'unschedules the delayed job and shows play button as a manual job' do + find('#ci-badge-delayed-job .ci-action-icon-container').click + + page.within('#ci-badge-delayed-job') do + expect(page).to have_css('.js-icon-play') + end + end + end + context 'when pipeline has failed builds' do it 'shows the failed icon and a retry action for the failed build' do page.within('#ci-badge-test') do @@ -315,6 +343,18 @@ describe 'Pipeline', :js do it { expect(build_manual.reload).to be_pending } end + context 'when user unschedules a delayed job' do + before do + within '.pipeline-holder' do + click_link('Unschedule') + end + end + + it 'unschedules the delayed job and shows play button as a manual job' do + expect(page).to have_content('Trigger this manual action') + end + end + context 'failed jobs' do it 'displays a tooltip with the failure reason' do page.within('.ci-table') do diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 41822babbc9..17772a35779 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -232,6 +232,60 @@ describe 'Pipelines', :js do end end + context 'when there is a delayed job' do + let!(:delayed_job) do + create(:ci_build, :scheduled, + pipeline: pipeline, + name: 'delayed job', + stage: 'test', + commands: 'test') + end + + before do + visit_project_pipelines + end + + it 'has a dropdown for actionable jobs' do + expect(page).to have_selector('.dropdown-new.btn.btn-default .icon-play') + end + + it "has link to the delayed job's action" do + find('.js-pipeline-dropdown-manual-actions').click + + time_diff = [0, delayed_job.scheduled_at - Time.now].max + expect(page).to have_button('delayed job') + expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M:%S")) + end + + context 'when delayed job is expired already' do + let!(:delayed_job) do + create(:ci_build, :expired_scheduled, + pipeline: pipeline, + name: 'delayed job', + stage: 'test', + commands: 'test') + end + + it "shows 00:00:00 as the remaining time" do + find('.js-pipeline-dropdown-manual-actions').click + + expect(page).to have_content("00:00:00") + end + end + + context 'when user played a delayed job immediately' do + before do + find('.js-pipeline-dropdown-manual-actions').click + page.accept_confirm { click_button('delayed job') } + wait_for_requests + end + + it 'enqueues the delayed job', :js do + expect(delayed_job.reload).to be_pending + end + end + end + context 'for generic statuses' do context 'when running' do let!(:running) do diff --git a/spec/helpers/time_helper_spec.rb b/spec/helpers/time_helper_spec.rb index 0b371d69ecf..cc310766433 100644 --- a/spec/helpers/time_helper_spec.rb +++ b/spec/helpers/time_helper_spec.rb @@ -20,17 +20,35 @@ describe TimeHelper do end describe "#duration_in_numbers" do - it "returns minutes and seconds" do - durations_and_expectations = { - 100 => "01:40", - 121 => "02:01", - 3721 => "01:02:01", - 0 => "00:00", - 42 => "00:42" - } + using RSpec::Parameterized::TableSyntax + + context "without passing allow_overflow" do + where(:duration, :formatted_string) do + 0 | "00:00" + 1.second | "00:01" + 42.seconds | "00:42" + 2.minutes + 1.second | "02:01" + 3.hours + 2.minutes + 1.second | "03:02:01" + 30.hours | "06:00:00" + end + + with_them do + it { expect(duration_in_numbers(duration)).to eq formatted_string } + end + end + + context "with allow_overflow = true" do + where(:duration, :formatted_string) do + 0 | "00:00:00" + 1.second | "00:00:01" + 42.seconds | "00:00:42" + 2.minutes + 1.second | "00:02:01" + 3.hours + 2.minutes + 1.second | "03:02:01" + 30.hours | "30:00:00" + end - durations_and_expectations.each do |duration, expectation| - expect(duration_in_numbers(duration)).to eq(expectation) + with_them do + it { expect(duration_in_numbers(duration, true)).to eq formatted_string } end end end diff --git a/spec/javascripts/datetime_utility_spec.js b/spec/javascripts/datetime_utility_spec.js index 492171684dc..6c3e73f134e 100644 --- a/spec/javascripts/datetime_utility_spec.js +++ b/spec/javascripts/datetime_utility_spec.js @@ -6,9 +6,7 @@ describe('Date time utils', () => { const date = new Date(); date.setFullYear(date.getFullYear() - 1); - expect( - datetimeUtility.timeFor(date), - ).toBe('Past due'); + expect(datetimeUtility.timeFor(date)).toBe('Past due'); }); it('returns remaining time when in the future', () => { @@ -19,9 +17,7 @@ describe('Date time utils', () => { // short of a full year, timeFor will return '11 months remaining' date.setDate(date.getDate() + 1); - expect( - datetimeUtility.timeFor(date), - ).toBe('1 year remaining'); + expect(datetimeUtility.timeFor(date)).toBe('1 year remaining'); }); }); @@ -168,3 +164,20 @@ describe('getTimeframeWindowFrom', () => { }); }); }); + +describe('formatTime', () => { + const expectedTimestamps = [ + [0, '00:00:00'], + [1000, '00:00:01'], + [42000, '00:00:42'], + [121000, '00:02:01'], + [10921000, '03:02:01'], + [108000000, '30:00:00'], + ]; + + expectedTimestamps.forEach(([milliseconds, expectedTimestamp]) => { + it(`formats ${milliseconds}ms as ${expectedTimestamp}`, () => { + expect(datetimeUtility.formatTime(milliseconds)).toBe(expectedTimestamp); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipelines_actions_spec.js b/spec/javascripts/pipelines/pipelines_actions_spec.js index 72fb0a8f9ef..0566bc55693 100644 --- a/spec/javascripts/pipelines/pipelines_actions_spec.js +++ b/spec/javascripts/pipelines/pipelines_actions_spec.js @@ -1,46 +1,98 @@ import Vue from 'vue'; -import pipelinesActionsComp from '~/pipelines/components/pipelines_actions.vue'; +import eventHub from '~/pipelines/event_hub'; +import PipelinesActions from '~/pipelines/components/pipelines_actions.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'spec/test_constants'; describe('Pipelines Actions dropdown', () => { - let component; - let actions; - let ActionsComponent; + const Component = Vue.extend(PipelinesActions); + let vm; - beforeEach(() => { - ActionsComponent = Vue.extend(pipelinesActionsComp); + afterEach(() => { + vm.$destroy(); + }); - actions = [ + describe('manual actions', () => { + const actions = [ { name: 'stop_review', - path: '/root/review-app/builds/1893/play', + path: `${TEST_HOST}/root/review-app/builds/1893/play`, }, { name: 'foo', - path: '#', + path: `${TEST_HOST}/disabled/pipeline/action`, playable: false, }, ]; - component = new ActionsComponent({ - propsData: { - actions, - }, - }).$mount(); - }); + beforeEach(() => { + vm = mountComponent(Component, { actions }); + }); - it('should render a dropdown with the provided actions', () => { - expect( - component.$el.querySelectorAll('.dropdown-menu li').length, - ).toEqual(actions.length); + it('renders a dropdown with the provided actions', () => { + const dropdownItems = vm.$el.querySelectorAll('.dropdown-menu li'); + expect(dropdownItems.length).toEqual(actions.length); + }); + + it("renders a disabled action when it's not playable", () => { + const dropdownItem = vm.$el.querySelector('.dropdown-menu li:last-child button'); + expect(dropdownItem).toBeDisabled(); + }); }); - it('should render a disabled action when it\'s not playable', () => { - expect( - component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'), - ).toEqual('disabled'); + describe('scheduled jobs', () => { + const scheduledJobAction = { + name: 'scheduled action', + path: `${TEST_HOST}/scheduled/job/action`, + playable: true, + scheduled_at: '2063-04-05T00:42:00Z', + }; + const expiredJobAction = { + name: 'expired action', + path: `${TEST_HOST}/expired/job/action`, + playable: true, + scheduled_at: '2018-10-05T08:23:00Z', + }; + const findDropdownItem = action => { + const buttons = vm.$el.querySelectorAll('.dropdown-menu li button'); + return Array.prototype.find.call(buttons, element => + element.innerText.trim().startsWith(action.name), + ); + }; + + beforeEach(() => { + spyOn(Date, 'now').and.callFake(() => new Date('2063-04-04T00:42:00Z').getTime()); + vm = mountComponent(Component, { actions: [scheduledJobAction, expiredJobAction] }); + }); + + it('emits postAction event after confirming', () => { + const emitSpy = jasmine.createSpy('emit'); + eventHub.$on('postAction', emitSpy); + spyOn(window, 'confirm').and.callFake(() => true); + + findDropdownItem(scheduledJobAction).click(); + + expect(window.confirm).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith(scheduledJobAction.path); + }); + + it('does not emit postAction event if confirmation is cancelled', () => { + const emitSpy = jasmine.createSpy('emit'); + eventHub.$on('postAction', emitSpy); + spyOn(window, 'confirm').and.callFake(() => false); + + findDropdownItem(scheduledJobAction).click(); + + expect(window.confirm).toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('displays the remaining time in the dropdown', () => { + expect(findDropdownItem(scheduledJobAction)).toContainText('24:00:00'); + }); - expect( - component.$el.querySelector('.dropdown-menu li:last-child button').classList.contains('disabled'), - ).toEqual(true); + it('displays 00:00:00 for expired jobs in the dropdown', () => { + expect(findDropdownItem(expiredJobAction)).toContainText('00:00:00'); + }); }); }); diff --git a/spec/javascripts/pipelines/pipelines_table_row_spec.js b/spec/javascripts/pipelines/pipelines_table_row_spec.js index 03ffc122795..42795f5c134 100644 --- a/spec/javascripts/pipelines/pipelines_table_row_spec.js +++ b/spec/javascripts/pipelines/pipelines_table_row_spec.js @@ -158,8 +158,13 @@ describe('Pipelines Table Row', () => { }); describe('actions column', () => { + const scheduledJobAction = { + name: 'some scheduled job', + }; + beforeEach(() => { const withActions = Object.assign({}, pipeline); + withActions.details.scheduled_actions = [scheduledJobAction]; withActions.flags.cancelable = true; withActions.flags.retryable = true; withActions.cancel_path = '/cancel'; @@ -171,6 +176,8 @@ describe('Pipelines Table Row', () => { it('should render the provided actions', () => { expect(component.$el.querySelector('.js-pipelines-retry-button')).not.toBeNull(); expect(component.$el.querySelector('.js-pipelines-cancel-button')).not.toBeNull(); + const dropdownMenu = component.$el.querySelectorAll('.dropdown-menu'); + expect(dropdownMenu).toContainText(scheduledJobAction.name); }); it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => { diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index 2c9758401b7..1169938b80c 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -39,6 +39,14 @@ describe Gitlab::Ci::Config::Entry::Job do expect(entry.errors).to include "job name can't be blank" end end + + context 'when delayed job' do + context 'when start_in is specified' do + let(:config) { { script: 'echo', when: 'delayed', start_in: '1 day' } } + + it { expect(entry).to be_valid } + end + end end context 'when entry value is not correct' do @@ -129,6 +137,52 @@ describe Gitlab::Ci::Config::Entry::Job do end end end + + context 'when delayed job' do + context 'when start_in is specified' do + let(:config) { { script: 'echo', when: 'delayed', start_in: '1 day' } } + + it 'returns error about invalid type' do + expect(entry).to be_valid + end + end + + context 'when start_in is empty' do + let(:config) { { when: 'delayed', start_in: nil } } + + it 'returns error about invalid type' do + expect(entry).not_to be_valid + expect(entry.errors).to include 'job start in should be a duration' + end + end + + context 'when start_in is not formatted as a duration' do + let(:config) { { when: 'delayed', start_in: 'test' } } + + it 'returns error about invalid type' do + expect(entry).not_to be_valid + expect(entry.errors).to include 'job start in should be a duration' + end + end + + context 'when start_in is longer than one day' do + let(:config) { { when: 'delayed', start_in: '2 days' } } + + it 'returns error about exceeding the limit' do + expect(entry).not_to be_valid + expect(entry.errors).to include 'job start in should not exceed the limit' + end + end + end + + context 'when start_in specified without delayed specification' do + let(:config) { { start_in: '1 day' } } + + it 'returns error about invalid type' do + expect(entry).not_to be_valid + expect(entry.errors).to include 'job start in must be blank' + end + end end end @@ -238,6 +292,24 @@ describe Gitlab::Ci::Config::Entry::Job do end end + describe '#delayed?' do + context 'when job is a delayed' do + let(:config) { { script: 'deploy', when: 'delayed' } } + + it 'is a delayed' do + expect(entry).to be_delayed + end + end + + context 'when job is not a delayed' do + let(:config) { { script: 'deploy' } } + + it 'is not a delayed' do + expect(entry).not_to be_delayed + end + end + end + describe '#ignored?' do context 'when job is a manual action' do context 'when it is not specified if job is allowed to fail' do diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 8b92088902b..aa53ecd5967 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -319,4 +319,53 @@ describe Gitlab::Ci::Status::Build::Factory do end end end + + context 'when build is a delayed action' do + let(:build) { create(:ci_build, :scheduled) } + + it 'matches correct core status' do + expect(factory.core_status).to be_a Gitlab::Ci::Status::Scheduled + end + + it 'matches correct extended statuses' do + expect(factory.extended_statuses) + .to eq [Gitlab::Ci::Status::Build::Scheduled, + Gitlab::Ci::Status::Build::Unschedule, + Gitlab::Ci::Status::Build::Action] + end + + it 'fabricates action detailed status' do + expect(status).to be_a Gitlab::Ci::Status::Build::Action + end + + it 'fabricates status with correct details' do + expect(status.text).to eq 'scheduled' + expect(status.group).to eq 'scheduled' + expect(status.icon).to eq 'status_scheduled' + expect(status.favicon).to eq 'favicon_status_scheduled' + expect(status.illustration).to include(:image, :size, :title, :content) + expect(status.label).to include 'unschedule action' + expect(status).to have_details + expect(status.action_path).to include 'unschedule' + end + + context 'when user has ability to play action' do + it 'fabricates status that has action' do + expect(status).to have_action + end + end + + context 'when user does not have ability to play action' do + before do + allow(build.project).to receive(:empty_repo?).and_return(false) + + create(:protected_branch, :no_one_can_push, + name: build.ref, project: build.project) + end + + it 'fabricates status that has no action' do + expect(status).not_to have_action + end + end + end end diff --git a/spec/lib/gitlab/ci/status/build/scheduled_spec.rb b/spec/lib/gitlab/ci/status/build/scheduled_spec.rb new file mode 100644 index 00000000000..3098a17c50d --- /dev/null +++ b/spec/lib/gitlab/ci/status/build/scheduled_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Build::Scheduled do + let(:user) { create(:user) } + let(:project) { create(:project, :stubbed_repository) } + let(:build) { create(:ci_build, :scheduled, project: project) } + let(:status) { Gitlab::Ci::Status::Core.new(build, user) } + + subject { described_class.new(status) } + + describe '#illustration' do + it { expect(subject.illustration).to include(:image, :size, :title) } + end + + describe '#status_tooltip' do + context 'when scheduled_at is not expired' do + let(:build) { create(:ci_build, scheduled_at: 1.minute.since, project: project) } + + it 'shows execute_in of the scheduled job' do + Timecop.freeze do + expect(subject.status_tooltip).to include('00:01:00') + end + end + end + + context 'when scheduled_at is expired' do + let(:build) { create(:ci_build, :expired_scheduled, project: project) } + + it 'shows 00:00:00' do + Timecop.freeze do + expect(subject.status_tooltip).to include('00:00:00') + end + end + end + end + + describe '.matches?' do + subject { described_class.matches?(build, user) } + + context 'when build is scheduled and scheduled_at is present' do + let(:build) { create(:ci_build, :expired_scheduled, project: project) } + + it { is_expected.to be_truthy } + end + + context 'when build is scheduled' do + let(:build) { create(:ci_build, status: :scheduled, project: project) } + + it { is_expected.to be_falsy } + end + + context 'when scheduled_at is present' do + let(:build) { create(:ci_build, scheduled_at: 1.minute.since, project: project) } + + it { is_expected.to be_falsy } + end + end +end diff --git a/spec/lib/gitlab/ci/status/build/unschedule_spec.rb b/spec/lib/gitlab/ci/status/build/unschedule_spec.rb new file mode 100644 index 00000000000..ed046d66ca5 --- /dev/null +++ b/spec/lib/gitlab/ci/status/build/unschedule_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Build::Unschedule do + let(:status) { double('core status') } + let(:user) { double('user') } + + subject do + described_class.new(status) + end + + describe '#label' do + it { expect(subject.label).to eq 'unschedule action' } + end + + describe 'action details' do + let(:user) { create(:user) } + let(:build) { create(:ci_build) } + let(:status) { Gitlab::Ci::Status::Core.new(build, user) } + + describe '#has_action?' do + context 'when user is allowed to update build' do + before do + stub_not_protect_default_branch + + build.project.add_developer(user) + end + + it { is_expected.to have_action } + end + + context 'when user is not allowed to update build' do + it { is_expected.not_to have_action } + end + end + + describe '#action_path' do + it { expect(subject.action_path).to include "#{build.id}/unschedule" } + end + + describe '#action_icon' do + it { expect(subject.action_icon).to eq 'time-out' } + end + + describe '#action_title' do + it { expect(subject.action_title).to eq 'Unschedule' } + end + + describe '#action_button_title' do + it { expect(subject.action_button_title).to eq 'Unschedule job' } + end + end + + describe '.matches?' do + subject { described_class.matches?(build, user) } + + context 'when build is scheduled' do + context 'when build unschedules an delayed job' do + let(:build) { create(:ci_build, :scheduled) } + + it 'is a correct match' do + expect(subject).to be true + end + end + + context 'when build unschedules an normal job' do + let(:build) { create(:ci_build) } + + it 'does not match' do + expect(subject).to be false + end + end + end + end + + describe '#status_tooltip' do + it 'does not override status status_tooltip' do + expect(status).to receive(:status_tooltip) + + subject.status_tooltip + end + end + + describe '#badge_tooltip' do + let(:user) { create(:user) } + let(:build) { create(:ci_build, :playable) } + let(:status) { Gitlab::Ci::Status::Core.new(build, user) } + + it 'does not override status badge_tooltip' do + expect(status).to receive(:badge_tooltip) + + subject.badge_tooltip + end + end +end diff --git a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb index defb3fdc0df..694d4ce160a 100644 --- a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb @@ -11,8 +11,7 @@ describe Gitlab::Ci::Status::Pipeline::Factory do end context 'when pipeline has a core status' do - (HasStatus::AVAILABLE_STATUSES - [HasStatus::BLOCKED_STATUS]) - .each do |simple_status| + HasStatus::AVAILABLE_STATUSES.each do |simple_status| context "when core status is #{simple_status}" do let(:pipeline) { create(:ci_pipeline, status: simple_status) } @@ -24,12 +23,24 @@ describe Gitlab::Ci::Status::Pipeline::Factory do expect(factory.core_status).to be_a expected_status end - it 'does not match extended statuses' do - expect(factory.extended_statuses).to be_empty - end - - it "fabricates a core status #{simple_status}" do - expect(status).to be_a expected_status + if simple_status == 'manual' + it 'matches a correct extended statuses' do + expect(factory.extended_statuses) + .to eq [Gitlab::Ci::Status::Pipeline::Blocked] + end + elsif simple_status == 'scheduled' + it 'matches a correct extended statuses' do + expect(factory.extended_statuses) + .to eq [Gitlab::Ci::Status::Pipeline::Scheduled] + end + else + it 'does not match extended statuses' do + expect(factory.extended_statuses).to be_empty + end + + it "fabricates a core status #{simple_status}" do + expect(status).to be_a expected_status + end end it 'extends core status with common pipeline methods' do @@ -40,27 +51,6 @@ describe Gitlab::Ci::Status::Pipeline::Factory do end end end - - context "when core status is manual" do - let(:pipeline) { create(:ci_pipeline, status: :manual) } - - it "matches manual core status" do - expect(factory.core_status) - .to be_a Gitlab::Ci::Status::Manual - end - - it 'matches a correct extended statuses' do - expect(factory.extended_statuses) - .to eq [Gitlab::Ci::Status::Pipeline::Blocked] - end - - it 'extends core status with common pipeline methods' do - expect(status).to have_details - expect(status).not_to have_action - expect(status.details_path) - .to include "pipelines/#{pipeline.id}" - end - end end context 'when pipeline has warnings' do diff --git a/spec/lib/gitlab/ci/status/pipeline/scheduled_spec.rb b/spec/lib/gitlab/ci/status/pipeline/scheduled_spec.rb new file mode 100644 index 00000000000..29afa08b56b --- /dev/null +++ b/spec/lib/gitlab/ci/status/pipeline/scheduled_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Pipeline::Scheduled do + let(:pipeline) { double('pipeline') } + + subject do + described_class.new(pipeline) + end + + describe '#text' do + it 'overrides status text' do + expect(subject.text).to eq 'scheduled' + end + end + + describe '#label' do + it 'overrides status label' do + expect(subject.label).to eq 'waiting for delayed job' + end + end + + describe '.matches?' do + let(:user) { double('user') } + subject { described_class.matches?(pipeline, user) } + + context 'when pipeline is scheduled' do + let(:pipeline) { create(:ci_pipeline, :scheduled) } + + it 'is a correct match' do + expect(subject).to be true + end + end + + context 'when pipeline is not scheduled' do + let(:pipeline) { create(:ci_pipeline, :success) } + + it 'does not match' do + expect(subject).to be false + end + end + end +end diff --git a/spec/lib/gitlab/ci/status/scheduled_spec.rb b/spec/lib/gitlab/ci/status/scheduled_spec.rb new file mode 100644 index 00000000000..c35a6f43d5d --- /dev/null +++ b/spec/lib/gitlab/ci/status/scheduled_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Scheduled do + subject do + described_class.new(double('subject'), double('user')) + end + + describe '#text' do + it { expect(subject.text).to eq 'scheduled' } + end + + describe '#label' do + it { expect(subject.label).to eq 'scheduled' } + end + + describe '#icon' do + it { expect(subject.icon).to eq 'status_scheduled' } + end + + describe '#favicon' do + it { expect(subject.favicon).to eq 'favicon_status_scheduled' } + end + + describe '#group' do + it { expect(subject.group).to eq 'scheduled' } + end +end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index 564635cec2b..85b23edce9f 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -121,6 +121,21 @@ module Gitlab end end end + + describe 'delayed job entry' do + context 'when delayed is defined' do + let(:config) do + YAML.dump(rspec: { script: 'rollout 10%', + when: 'delayed', + start_in: '1 day' }) + end + + it 'has the attributes' do + expect(subject[:when]).to eq 'delayed' + expect(subject[:options][:start_in]).to eq '1 day' + end + end + end end describe '#stages_attributes' do @@ -1260,7 +1275,7 @@ module Gitlab config = YAML.dump({ rspec: { script: "test", when: 1 } }) expect do Gitlab::Ci::YamlProcessor.new(config) - end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec when should be on_success, on_failure, always or manual") + end.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, "jobs:rspec when should be on_success, on_failure, always, manual or delayed") end it "returns errors if job artifacts:name is not an a string" do diff --git a/spec/lib/gitlab/favicon_spec.rb b/spec/lib/gitlab/favicon_spec.rb index 68abcb3520a..49a423191bb 100644 --- a/spec/lib/gitlab/favicon_spec.rb +++ b/spec/lib/gitlab/favicon_spec.rb @@ -58,6 +58,7 @@ RSpec.describe Gitlab::Favicon, :request_store do favicon_status_not_found favicon_status_pending favicon_status_running + favicon_status_scheduled favicon_status_skipped favicon_status_success favicon_status_warning diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index ec2bdbe22e1..fe167033941 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -117,6 +117,7 @@ pipelines: - retryable_builds - cancelable_statuses - manual_actions +- scheduled_actions - artifacts - pipeline_schedule - merge_requests diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 7be1bf6e0bf..f7935149b23 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -300,6 +300,7 @@ CommitStatus: - retried - protected - failure_reason +- scheduled_at Ci::Variable: - id - project_id diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 70d9af2f74d..cebc822d525 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -209,6 +209,155 @@ describe Ci::Build do end end + describe '#schedulable?' do + subject { build.schedulable? } + + context 'when build is schedulable' do + let(:build) { create(:ci_build, :created, :schedulable, project: project) } + + it { expect(subject).to be_truthy } + + context 'when feature flag is diabled' do + before do + stub_feature_flags(ci_enable_scheduled_build: false) + end + + it { expect(subject).to be_falsy } + end + end + + context 'when build is not schedulable' do + let(:build) { create(:ci_build, :created, project: project) } + + it { expect(subject).to be_falsy } + end + end + + describe '#schedule' do + subject { build.schedule } + + before do + project.add_developer(user) + end + + let(:build) { create(:ci_build, :created, :schedulable, user: user, project: project) } + + it 'transits to scheduled' do + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + + subject + + expect(build).to be_scheduled + end + + it 'updates scheduled_at column' do + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + + subject + + expect(build.scheduled_at).not_to be_nil + end + + it 'schedules BuildScheduleWorker at the right time' do + Timecop.freeze do + expect(Ci::BuildScheduleWorker) + .to receive(:perform_at).with(1.minute.since, build.id) + + subject + end + end + end + + describe '#unschedule' do + subject { build.unschedule } + + context 'when build is scheduled' do + let(:build) { create(:ci_build, :scheduled, pipeline: pipeline) } + + it 'cleans scheduled_at column' do + subject + + expect(build.scheduled_at).to be_nil + end + + it 'transits to manual' do + subject + + expect(build).to be_manual + end + end + + context 'when build is not scheduled' do + let(:build) { create(:ci_build, :created, pipeline: pipeline) } + + it 'does not transit status' do + subject + + expect(build).to be_created + end + end + end + + describe '#options_scheduled_at' do + subject { build.options_scheduled_at } + + let(:build) { build_stubbed(:ci_build, options: option) } + + context 'when start_in is 1 day' do + let(:option) { { start_in: '1 day' } } + + it 'returns date after 1 day' do + Timecop.freeze do + is_expected.to eq(1.day.since) + end + end + end + + context 'when start_in is 1 week' do + let(:option) { { start_in: '1 week' } } + + it 'returns date after 1 week' do + Timecop.freeze do + is_expected.to eq(1.week.since) + end + end + end + end + + describe '#enqueue_scheduled' do + subject { build.enqueue_scheduled } + + before do + stub_feature_flags(ci_enable_scheduled_build: true) + end + + context 'when build is scheduled and the right time has not come yet' do + let(:build) { create(:ci_build, :scheduled, pipeline: pipeline) } + + it 'does not transits the status' do + subject + + expect(build).to be_scheduled + end + end + + context 'when build is scheduled and the right time has already come' do + let(:build) { create(:ci_build, :expired_scheduled, pipeline: pipeline) } + + it 'cleans scheduled_at column' do + subject + + expect(build.scheduled_at).to be_nil + end + + it 'transits to pending' do + subject + + expect(build).to be_pending + end + end + end + describe '#any_runners_online?' do subject { build.any_runners_online? } @@ -1193,6 +1342,12 @@ describe Ci::Build do it { is_expected.to be_truthy } end + context 'when is set to delayed' do + let(:value) { 'delayed' } + + it { is_expected.to be_truthy } + end + context 'when set to something else' do let(:value) { 'something else' } @@ -1476,6 +1631,12 @@ describe Ci::Build do end end + context 'when build is scheduled' do + subject { build_stubbed(:ci_build, :scheduled) } + + it { is_expected.to be_playable } + end + context 'when build is not a manual action' do subject { build_stubbed(:ci_build, :success) } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index b56c7f26864..3b01b39ecab 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -75,6 +75,18 @@ describe Ci::Pipeline, :mailer do end end + describe '#delay' do + subject { pipeline.delay } + + let(:pipeline) { build(:ci_pipeline, status: :created) } + + it 'changes pipeline status to schedule' do + subject + + expect(pipeline).to be_scheduled + end + end + describe '#valid_commit_sha' do context 'commit.sha can not start with 00000000' do before do @@ -1339,6 +1351,19 @@ describe Ci::Pipeline, :mailer do end end + context 'when updating status to scheduled' do + before do + allow(pipeline) + .to receive_message_chain(:statuses, :latest, :status) + .and_return(:scheduled) + end + + it 'updates pipeline status to scheduled' do + expect { pipeline.update_status } + .to change { pipeline.reload.status }.to 'scheduled' + end + end + context 'when statuses status was not recognized' do before do allow(pipeline) diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index 22a4556c10c..5076f7faeac 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -89,6 +89,18 @@ describe Ci::Stage, :models do end end + context 'when stage is scheduled because of scheduled builds' do + before do + create(:ci_build, :scheduled, stage_id: stage.id) + end + + it 'updates status to scheduled' do + expect { stage.update_status } + .to change { stage.reload.status } + .to 'scheduled' + end + end + context 'when stage is skipped because is empty' do it 'updates status to skipped' do expect { stage.update_status } @@ -188,6 +200,18 @@ describe Ci::Stage, :models do end end + describe '#delay' do + subject { stage.delay } + + let(:stage) { create(:ci_stage_entity, status: :created) } + + it 'updates stage status' do + subject + + expect(stage).to be_scheduled + end + end + describe '#position' do context 'when stage has been imported and does not have position index set' do before do diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index f3f2bc28d2c..917685399d4 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -129,6 +129,20 @@ describe CommitStatus do end end + describe '#cancel' do + subject { job.cancel } + + context 'when status is scheduled' do + let(:job) { build(:commit_status, :scheduled) } + + it 'updates the status' do + subject + + expect(job).to be_canceled + end + end + end + describe '#auto_canceled?' do subject { commit_status.auto_canceled? } @@ -564,6 +578,12 @@ describe CommitStatus do it_behaves_like 'commit status enqueued' end + + context 'when initial state is :scheduled' do + let(:commit_status) { create(:commit_status, :scheduled) } + + it_behaves_like 'commit status enqueued' + end end describe '#present' do diff --git a/spec/models/concerns/has_status_spec.rb b/spec/models/concerns/has_status_spec.rb index 6866b43432c..6b1038cb8fd 100644 --- a/spec/models/concerns/has_status_spec.rb +++ b/spec/models/concerns/has_status_spec.rb @@ -270,11 +270,11 @@ describe HasStatus do describe '.cancelable' do subject { CommitStatus.cancelable } - %i[running pending created].each do |status| + %i[running pending created scheduled].each do |status| it_behaves_like 'containing the job', status end - %i[failed success skipped canceled].each do |status| + %i[failed success skipped canceled manual].each do |status| it_behaves_like 'not containing the job', status end end @@ -290,6 +290,18 @@ describe HasStatus do it_behaves_like 'not containing the job', status end end + + describe '.scheduled' do + subject { CommitStatus.scheduled } + + %i[scheduled].each do |status| + it_behaves_like 'containing the job', status + end + + %i[failed success skipped canceled].each do |status| + it_behaves_like 'not containing the job', status + end + end end describe '::DEFAULT_STATUS' do @@ -300,7 +312,41 @@ describe HasStatus do describe '::BLOCKED_STATUS' do it 'is a status manual' do - expect(described_class::BLOCKED_STATUS).to eq 'manual' + expect(described_class::BLOCKED_STATUS).to eq %w[manual scheduled] + end + end + + describe 'blocked?' do + subject { object.blocked? } + + %w[ci_pipeline ci_stage ci_build generic_commit_status].each do |type| + let(:object) { build(type, status: status) } + + context 'when status is scheduled' do + let(:status) { :scheduled } + + it { is_expected.to be_truthy } + end + + context 'when status is manual' do + let(:status) { :manual } + + it { is_expected.to be_truthy } + end + + context 'when status is created' do + let(:status) { :created } + + it { is_expected.to be_falsy } + end + end + end + + describe '.status_sql' do + subject { Ci::Build.status_sql } + + it 'returns SQL' do + puts subject end end end diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb index 547d95e0908..b2fe10bb0b0 100644 --- a/spec/presenters/ci/build_presenter_spec.rb +++ b/spec/presenters/ci/build_presenter_spec.rb @@ -218,6 +218,42 @@ describe Ci::BuildPresenter do end end + describe '#execute_in' do + subject { presenter.execute_in } + + context 'when build is scheduled' do + context 'when schedule is not expired' do + let(:build) { create(:ci_build, :scheduled) } + + it 'returns execution time' do + Timecop.freeze do + is_expected.to eq(60.0) + end + end + end + + context 'when schedule is expired' do + let(:build) { create(:ci_build, :expired_scheduled) } + + it 'returns execution time' do + Timecop.freeze do + is_expected.to eq(0) + end + end + end + end + + context 'when build is not delayed' do + let(:build) { create(:ci_build) } + + it 'does not return execution time' do + Timecop.freeze do + is_expected.to be_falsy + end + end + end + end + describe '#callout_failure_message' do let(:build) { create(:ci_build, :failed, :api_failure) } diff --git a/spec/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb index 15720d86583..9e2bee2ee60 100644 --- a/spec/serializers/build_action_entity_spec.rb +++ b/spec/serializers/build_action_entity_spec.rb @@ -22,5 +22,17 @@ describe BuildActionEntity do it 'contains whether it is playable' do expect(subject[:playable]).to eq job.playable? end + + context 'when job is scheduled' do + let(:job) { create(:ci_build, :scheduled) } + + it 'returns scheduled_at' do + expect(subject[:scheduled_at]).to eq(job.scheduled_at) + end + + it 'returns unschedule path' do + expect(subject[:unschedule_path]).to include "jobs/#{job.id}/unschedule" + end + end end end diff --git a/spec/serializers/job_entity_spec.rb b/spec/serializers/job_entity_spec.rb index 8e1ca3f308d..5fc27da4906 100644 --- a/spec/serializers/job_entity_spec.rb +++ b/spec/serializers/job_entity_spec.rb @@ -109,6 +109,18 @@ describe JobEntity do end end + context 'when job is scheduled' do + let(:job) { create(:ci_build, :scheduled) } + + it 'contains path to unschedule action' do + expect(subject).to include(:unschedule_path) + end + + it 'contains scheduled_at' do + expect(subject[:scheduled_at]).to eq(job.scheduled_at) + end + end + context 'when job is generic commit status' do let(:job) { create(:generic_commit_status, target_url: 'http://google.com') } diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb index 45e18086894..8e73a3e67c6 100644 --- a/spec/serializers/pipeline_details_entity_spec.rb +++ b/spec/serializers/pipeline_details_entity_spec.rb @@ -29,7 +29,7 @@ describe PipelineDetailsEntity do expect(subject[:details]) .to include :duration, :finished_at expect(subject[:details]) - .to include :stages, :artifacts, :manual_actions + .to include :stages, :artifacts, :manual_actions, :scheduled_actions expect(subject[:details][:status]).to include :icon, :favicon, :text, :label end diff --git a/spec/services/ci/enqueue_build_service_spec.rb b/spec/services/ci/enqueue_build_service_spec.rb deleted file mode 100644 index e41b8e4800b..00000000000 --- a/spec/services/ci/enqueue_build_service_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -require 'spec_helper' - -describe Ci::EnqueueBuildService, '#execute' do - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:ci_build) { create(:ci_build, :created) } - - subject { described_class.new(project, user).execute(ci_build) } - - it 'enqueues the build' do - subject - - expect(ci_build.pending?).to be_truthy - end -end diff --git a/spec/services/ci/process_build_service_spec.rb b/spec/services/ci/process_build_service_spec.rb new file mode 100644 index 00000000000..9f47439dc4a --- /dev/null +++ b/spec/services/ci/process_build_service_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Ci::ProcessBuildService, '#execute' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + subject { described_class.new(project, user).execute(build, current_status) } + + before do + project.add_maintainer(user) + end + + shared_examples_for 'Enqueuing properly' do |valid_statuses_for_when| + valid_statuses_for_when.each do |status_for_prior_stages| + context "when status for prior stages is #{status_for_prior_stages}" do + let(:current_status) { status_for_prior_stages } + + %w[created skipped manual scheduled].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, when: when_option, user: user, project: project) } + + it 'enqueues the build' do + expect { subject }.to change { build.status }.to('pending') + end + end + end + + %w[pending running success failed canceled].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, when: when_option, user: user, project: project) } + + it 'does not change the build status' do + expect { subject }.not_to change { build.status } + end + end + end + end + end + + (HasStatus::AVAILABLE_STATUSES - valid_statuses_for_when).each do |status_for_prior_stages| + let(:current_status) { status_for_prior_stages } + + context "when status for prior stages is #{status_for_prior_stages}" do + %w[created pending].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, when: when_option, user: user, project: project) } + + it 'skips the build' do + expect { subject }.to change { build.status }.to('skipped') + end + end + end + + (HasStatus::AVAILABLE_STATUSES - %w[created pending]).each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, when: when_option, user: user, project: project) } + + it 'does not change build status' do + expect { subject }.not_to change { build.status } + end + end + end + end + end + end + + shared_examples_for 'Actionizing properly' do |valid_statuses_for_when| + valid_statuses_for_when.each do |status_for_prior_stages| + context "when status for prior stages is #{status_for_prior_stages}" do + let(:current_status) { status_for_prior_stages } + + %w[created].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :actionable, user: user, project: project) } + + it 'enqueues the build' do + expect { subject }.to change { build.status }.to('manual') + end + end + end + + %w[manual skipped pending running success failed canceled scheduled].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :actionable, user: user, project: project) } + + it 'does not change the build status' do + expect { subject }.not_to change { build.status } + end + end + end + end + end + + (HasStatus::AVAILABLE_STATUSES - valid_statuses_for_when).each do |status_for_prior_stages| + let(:current_status) { status_for_prior_stages } + + context "when status for prior stages is #{status_for_prior_stages}" do + %w[created pending].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :actionable, user: user, project: project) } + + it 'skips the build' do + expect { subject }.to change { build.status }.to('skipped') + end + end + end + + (HasStatus::AVAILABLE_STATUSES - %w[created pending]).each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :actionable, user: user, project: project) } + + it 'does not change build status' do + expect { subject }.not_to change { build.status } + end + end + end + end + end + end + + shared_examples_for 'Scheduling properly' do |valid_statuses_for_when| + valid_statuses_for_when.each do |status_for_prior_stages| + context "when status for prior stages is #{status_for_prior_stages}" do + let(:current_status) { status_for_prior_stages } + + %w[created].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :schedulable, user: user, project: project) } + + it 'enqueues the build' do + expect { subject }.to change { build.status }.to('scheduled') + end + end + end + + %w[manual skipped pending running success failed canceled scheduled].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :schedulable, user: user, project: project) } + + it 'does not change the build status' do + expect { subject }.not_to change { build.status } + end + end + end + end + end + + (HasStatus::AVAILABLE_STATUSES - valid_statuses_for_when).each do |status_for_prior_stages| + let(:current_status) { status_for_prior_stages } + + context "when status for prior stages is #{status_for_prior_stages}" do + %w[created pending].each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :schedulable, user: user, project: project) } + + it 'skips the build' do + expect { subject }.to change { build.status }.to('skipped') + end + end + end + + (HasStatus::AVAILABLE_STATUSES - %w[created pending]).each do |status| + context "when build status is #{status}" do + let(:build) { create(:ci_build, status.to_sym, :schedulable, user: user, project: project) } + + it 'does not change build status' do + expect { subject }.not_to change { build.status } + end + end + end + end + end + end + + context 'when build has on_success option' do + let(:when_option) { :on_success } + + it_behaves_like 'Enqueuing properly', %w[success skipped] + end + + context 'when build has on_failure option' do + let(:when_option) { :on_failure } + + it_behaves_like 'Enqueuing properly', %w[failed] + end + + context 'when build has always option' do + let(:when_option) { :always } + + it_behaves_like 'Enqueuing properly', %w[success failed skipped] + end + + context 'when build has manual option' do + let(:when_option) { :manual } + + it_behaves_like 'Actionizing properly', %w[success skipped] + end + + context 'when build has delayed option' do + let(:when_option) { :delayed } + + before do + allow(Ci::BuildScheduleWorker).to receive(:perform_at) { } + end + + context 'when ci_enable_scheduled_build is enabled' do + before do + stub_feature_flags(ci_enable_scheduled_build: true) + end + + it_behaves_like 'Scheduling properly', %w[success skipped] + end + + context 'when ci_enable_scheduled_build is enabled' do + before do + stub_feature_flags(ci_enable_scheduled_build: false) + end + + it_behaves_like 'Actionizing properly', %w[success skipped] + end + end +end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index feb5120bc68..8c7258c42ad 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -31,17 +31,14 @@ describe Ci::ProcessPipelineService, '#execute' do succeed_pending expect(builds.success.count).to eq(2) - expect(process_pipeline).to be_truthy succeed_pending expect(builds.success.count).to eq(4) - expect(process_pipeline).to be_truthy succeed_pending expect(builds.success.count).to eq(5) - expect(process_pipeline).to be_falsey end it 'does not process pipeline if existing stage is running' do @@ -242,6 +239,187 @@ describe Ci::ProcessPipelineService, '#execute' do end end + context 'when delayed jobs are defined' do + context 'when the scene is timed incremental rollout' do + before do + create_build('build', stage_idx: 0) + create_build('rollout10%', **delayed_options, stage_idx: 1) + create_build('rollout100%', **delayed_options, stage_idx: 2) + create_build('cleanup', stage_idx: 3) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + context 'when builds are successful' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + enqueue_scheduled('rollout10%') + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'scheduled' }) + + enqueue_scheduled('rollout100%') + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'success', 'cleanup': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'success', 'cleanup': 'success' }) + expect(pipeline.reload.status).to eq 'success' + end + end + + context 'when build job fails' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + fail_running_or_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'failed' }) + expect(pipeline.reload.status).to eq 'failed' + end + end + + context 'when rollout 10% is unscheduled' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + unschedule + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'manual' }) + expect(pipeline.reload.status).to eq 'manual' + end + + context 'when user plays rollout 10%' do + it 'schedules rollout100%' do + process_pipeline + succeed_pending + unschedule + play_manual_action('rollout10%') + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'scheduled' }) + expect(pipeline.reload.status).to eq 'scheduled' + end + end + end + + context 'when rollout 10% fails' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + enqueue_scheduled('rollout10%') + fail_running_or_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'failed' }) + expect(pipeline.reload.status).to eq 'failed' + end + + context 'when user retries rollout 10%' do + it 'does not schedule rollout10% again' do + process_pipeline + succeed_pending + enqueue_scheduled('rollout10%') + fail_running_or_pending + retry_build('rollout10%') + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'pending' }) + expect(pipeline.reload.status).to eq 'running' + end + end + end + + context 'when rollout 10% is played immidiately' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + play_manual_action('rollout10%') + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'pending' }) + expect(pipeline.reload.status).to eq 'running' + end + end + end + + context 'when only one scheduled job exists in a pipeline' do + before do + create_build('delayed', **delayed_options, stage_idx: 0) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'delayed': 'scheduled' }) + + expect(pipeline.reload.status).to eq 'scheduled' + end + end + + context 'when there are two delayed jobs in a stage' do + before do + create_build('delayed1', **delayed_options, stage_idx: 0) + create_build('delayed2', **delayed_options, stage_idx: 0) + create_build('job', stage_idx: 1) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + it 'blocks the stage until all scheduled jobs finished' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'delayed1': 'scheduled', 'delayed2': 'scheduled' }) + + enqueue_scheduled('delayed1') + + expect(builds_names_and_statuses).to eq({ 'delayed1': 'pending', 'delayed2': 'scheduled' }) + expect(pipeline.reload.status).to eq 'running' + end + end + + context 'when a delayed job is allowed to fail' do + before do + create_build('delayed', **delayed_options, allow_failure: true, stage_idx: 0) + create_build('job', stage_idx: 1) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + it 'blocks the stage and continues after it failed' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'delayed': 'scheduled' }) + + enqueue_scheduled('delayed') + fail_running_or_pending + + expect(builds_names_and_statuses).to eq({ 'delayed': 'failed', 'job': 'pending' }) + expect(pipeline.reload.status).to eq 'pending' + end + end + end + context 'when there are manual action in earlier stages' do context 'when first stage has only optional manual actions' do before do @@ -536,6 +714,13 @@ describe Ci::ProcessPipelineService, '#execute' do builds.pluck(:name) end + def builds_names_and_statuses + builds.each_with_object({}) do |b, h| + h[b.name.to_sym] = b.status + h + end + end + def all_builds_names all_builds.pluck(:name) end @@ -549,7 +734,7 @@ describe Ci::ProcessPipelineService, '#execute' do end def succeed_pending - builds.pending.update_all(status: 'success') + builds.pending.map(&:success) end def succeed_running_or_pending @@ -568,6 +753,14 @@ describe Ci::ProcessPipelineService, '#execute' do builds.find_by(name: name).play(user) end + def enqueue_scheduled(name) + builds.scheduled.find_by(name: name).enqueue + end + + def retry_build(name) + Ci::Build.retry(builds.find_by(name: name), user) + end + def manual_actions pipeline.manual_actions(true) end @@ -575,4 +768,12 @@ describe Ci::ProcessPipelineService, '#execute' do def create_build(name, **opts) create(:ci_build, :created, pipeline: pipeline, name: name, **opts) end + + def delayed_options + { when: 'delayed', options: { start_in: '1 minute' } } + end + + def unschedule + pipeline.builds.scheduled.map(&:unschedule) + end end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 750ba1b821b..642de81ed52 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -27,7 +27,7 @@ describe Ci::RetryBuildService do job_artifacts_metadata job_artifacts_trace job_artifacts_junit job_artifacts_sast job_artifacts_dependency_scanning job_artifacts_container_scanning job_artifacts_dast - job_artifacts_codequality].freeze + job_artifacts_codequality scheduled_at].freeze IGNORE_ACCESSORS = %i[type lock_version target_url base_tags trace_sections @@ -44,7 +44,8 @@ describe Ci::RetryBuildService do create(:ci_build, :failed, :expired, :erased, :queued, :coverage, :tags, :allowed_to_fail, :on_tag, :triggered, :teardown_environment, description: 'my-job', stage: 'test', stage_id: stage.id, - pipeline: pipeline, auto_canceled_by: another_pipeline) + pipeline: pipeline, auto_canceled_by: another_pipeline, + scheduled_at: 10.seconds.since) end before do diff --git a/spec/services/ci/run_scheduled_build_service_spec.rb b/spec/services/ci/run_scheduled_build_service_spec.rb new file mode 100644 index 00000000000..2c921dac238 --- /dev/null +++ b/spec/services/ci/run_scheduled_build_service_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Ci::RunScheduledBuildService do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + subject { described_class.new(project, user).execute(build) } + + before do + stub_feature_flags(ci_enable_scheduled_build: true) + end + + context 'when user can update build' do + before do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: pipeline.ref, project: project) + end + + context 'when build is scheduled' do + context 'when scheduled_at is expired' do + let(:build) { create(:ci_build, :expired_scheduled, user: user, project: project, pipeline: pipeline) } + + it 'can run the build' do + expect { subject }.not_to raise_error + + expect(build).to be_pending + end + end + + context 'when scheduled_at is not expired' do + let(:build) { create(:ci_build, :scheduled, user: user, project: project, pipeline: pipeline) } + + it 'can not run the build' do + expect { subject }.to raise_error(StateMachines::InvalidTransition) + + expect(build).to be_scheduled + end + end + end + + context 'when build is not scheduled' do + let(:build) { create(:ci_build, :created, user: user, project: project, pipeline: pipeline) } + + it 'can not run the build' do + expect { subject }.to raise_error(StateMachines::InvalidTransition) + + expect(build).to be_created + end + end + end + + context 'when user can not update build' do + context 'when build is scheduled' do + let(:build) { create(:ci_build, :scheduled, user: user, project: project, pipeline: pipeline) } + + it 'can not run the build' do + expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError) + + expect(build).to be_scheduled + end + end + end +end diff --git a/spec/workers/ci/build_schedule_worker_spec.rb b/spec/workers/ci/build_schedule_worker_spec.rb new file mode 100644 index 00000000000..4a3fe84d7f7 --- /dev/null +++ b/spec/workers/ci/build_schedule_worker_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Ci::BuildScheduleWorker do + subject { described_class.new.perform(build.id) } + + context 'when build is found' do + context 'when build is scheduled' do + let(:build) { create(:ci_build, :scheduled) } + + it 'executes RunScheduledBuildService' do + expect_any_instance_of(Ci::RunScheduledBuildService) + .to receive(:execute).once + + subject + end + end + + context 'when build is not scheduled' do + let(:build) { create(:ci_build, :created) } + + it 'executes RunScheduledBuildService' do + expect_any_instance_of(Ci::RunScheduledBuildService) + .not_to receive(:execute) + + subject + end + end + end + + context 'when build is not found' do + let(:build) { build_stubbed(:ci_build, :scheduled) } + + it 'does nothing' do + expect_any_instance_of(Ci::RunScheduledBuildService) + .not_to receive(:execute) + + subject + end + end +end diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb index 856886e3df5..557934346c9 100644 --- a/spec/workers/stuck_ci_jobs_worker_spec.rb +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -127,6 +127,47 @@ describe StuckCiJobsWorker do end end + describe 'drop stale scheduled builds' do + let(:status) { 'scheduled' } + let(:updated_at) { } + + context 'when scheduled at 2 hours ago but it is not executed yet' do + let!(:job) { create(:ci_build, :scheduled, scheduled_at: 2.hours.ago) } + + it 'drops the stale scheduled build' do + expect(Ci::Build.scheduled.count).to eq(1) + expect(job).to be_scheduled + + worker.perform + job.reload + + expect(Ci::Build.scheduled.count).to eq(0) + expect(job).to be_failed + expect(job).to be_stale_schedule + end + end + + context 'when scheduled at 30 minutes ago but it is not executed yet' do + let!(:job) { create(:ci_build, :scheduled, scheduled_at: 30.minutes.ago) } + + it 'does not drop the stale scheduled build yet' do + expect(Ci::Build.scheduled.count).to eq(1) + expect(job).to be_scheduled + + worker.perform + + expect(Ci::Build.scheduled.count).to eq(1) + expect(job).to be_scheduled + end + end + + context 'when there are no stale scheduled builds' do + it 'does not drop the stale scheduled build yet' do + expect { worker.perform }.not_to raise_error + end + end + end + describe 'exclusive lease' do let(:status) { 'running' } let(:updated_at) { 2.days.ago } |