summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorKamil Trzciński <ayufan@ayufan.eu>2018-10-05 16:30:33 +0000
committerKamil Trzciński <ayufan@ayufan.eu>2018-10-05 16:30:33 +0000
commit059da9bc8eb9355a760031ef8e73b0aa6285012f (patch)
treeb6057c99d0c53951a650122d624dc37405194551 /spec
parent7f86172f806558d2b614abcb06cef0ea516c5900 (diff)
parent7542a5d102bc48f5f7b8104fda22f0975b2dd931 (diff)
downloadgitlab-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')
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb40
-rw-r--r--spec/factories/ci/builds.rb21
-rw-r--r--spec/factories/ci/pipelines.rb4
-rw-r--r--spec/factories/commit_statuses.rb4
-rw-r--r--spec/features/projects/jobs_spec.rb28
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb44
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb54
-rw-r--r--spec/helpers/time_helper_spec.rb38
-rw-r--r--spec/javascripts/datetime_utility_spec.js25
-rw-r--r--spec/javascripts/pipelines/pipelines_actions_spec.js104
-rw-r--r--spec/javascripts/pipelines/pipelines_table_row_spec.js7
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb72
-rw-r--r--spec/lib/gitlab/ci/status/build/factory_spec.rb49
-rw-r--r--spec/lib/gitlab/ci/status/build/scheduled_spec.rb58
-rw-r--r--spec/lib/gitlab/ci/status/build/unschedule_spec.rb94
-rw-r--r--spec/lib/gitlab/ci/status/pipeline/factory_spec.rb48
-rw-r--r--spec/lib/gitlab/ci/status/pipeline/scheduled_spec.rb42
-rw-r--r--spec/lib/gitlab/ci/status/scheduled_spec.rb27
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb17
-rw-r--r--spec/lib/gitlab/favicon_spec.rb1
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml1
-rw-r--r--spec/models/ci/build_spec.rb161
-rw-r--r--spec/models/ci/pipeline_spec.rb25
-rw-r--r--spec/models/ci/stage_spec.rb24
-rw-r--r--spec/models/commit_status_spec.rb20
-rw-r--r--spec/models/concerns/has_status_spec.rb52
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb36
-rw-r--r--spec/serializers/build_action_entity_spec.rb12
-rw-r--r--spec/serializers/job_entity_spec.rb12
-rw-r--r--spec/serializers/pipeline_details_entity_spec.rb2
-rw-r--r--spec/services/ci/enqueue_build_service_spec.rb16
-rw-r--r--spec/services/ci/process_build_service_spec.rb223
-rw-r--r--spec/services/ci/process_pipeline_service_spec.rb209
-rw-r--r--spec/services/ci/retry_build_service_spec.rb5
-rw-r--r--spec/services/ci/run_scheduled_build_service_spec.rb66
-rw-r--r--spec/workers/ci/build_schedule_worker_spec.rb40
-rw-r--r--spec/workers/stuck_ci_jobs_worker_spec.rb41
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 }