Commit 059da9bc authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'scheduled-manual-jobs' into 'master'

Delayed jobs

Closes #51352

See merge request gitlab-org/gitlab-ce!21767
parents 7f86172f 7542a5d1
...@@ -370,3 +370,24 @@ window.gl.utils = { ...@@ -370,3 +370,24 @@ window.gl.utils = {
getTimeago, getTimeago,
localTimeAgo, localTimeAgo,
}; };
/**
* Formats milliseconds as timestamp (e.g. 01:02:03).
* This takes durations longer than a day into account (e.g. two days would be 48:00:00).
*
* @param milliseconds
* @returns {string}
*/
export const formatTime = milliseconds => {
const remainingSeconds = Math.floor(milliseconds / 1000) % 60;
const remainingMinutes = Math.floor(milliseconds / 1000 / 60) % 60;
const remainingHours = Math.floor(milliseconds / 1000 / 60 / 60);
let formattedTime = '';
if (remainingHours < 10) formattedTime += '0';
formattedTime += `${remainingHours}:`;
if (remainingMinutes < 10) formattedTime += '0';
formattedTime += `${remainingMinutes}:`;
if (remainingSeconds < 10) formattedTime += '0';
formattedTime += remainingSeconds;
return formattedTime;
};
<script> <script>
import { s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import icon from '../../vue_shared/components/icon.vue'; import icon from '../../vue_shared/components/icon.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
...@@ -22,10 +24,24 @@ export default { ...@@ -22,10 +24,24 @@ export default {
}; };
}, },
methods: { methods: {
onClickAction(endpoint) { onClickAction(action) {
if (action.scheduled_at) {
const confirmationMessage = sprintf(
s__(
"DelayedJobs|Are you sure you want to run %{jobName} immediately? This job will run automatically after it's timer finishes.",
),
{ jobName: action.name },
);
// https://gitlab.com/gitlab-org/gitlab-ce/issues/52156
// eslint-disable-next-line no-alert
if (!window.confirm(confirmationMessage)) {
return;
}
}
this.isLoading = true; this.isLoading = true;
eventHub.$emit('postAction', endpoint); eventHub.$emit('postAction', action.path);
}, },
isActionDisabled(action) { isActionDisabled(action) {
...@@ -35,6 +51,11 @@ export default { ...@@ -35,6 +51,11 @@ export default {
return !action.playable; return !action.playable;
}, },
remainingTime(action) {
const remainingMilliseconds = new Date(action.scheduled_at).getTime() - Date.now();
return formatTime(Math.max(0, remainingMilliseconds));
},
}, },
}; };
</script> </script>
...@@ -63,17 +84,24 @@ export default { ...@@ -63,17 +84,24 @@ export default {
<ul class="dropdown-menu dropdown-menu-right"> <ul class="dropdown-menu dropdown-menu-right">
<li <li
v-for="(action, i) in actions" v-for="action in actions"
:key="i" :key="action.path"
> >
<button <button
:class="{ disabled: isActionDisabled(action) }" :class="{ disabled: isActionDisabled(action) }"
:disabled="isActionDisabled(action)" :disabled="isActionDisabled(action)"
type="button" type="button"
class="js-pipeline-action-link no-btn btn" class="js-pipeline-action-link no-btn btn"
@click="onClickAction(action.path)" @click="onClickAction(action)"
> >
{{ action.name }} {{ action.name }}
<span
v-if="action.scheduled_at"
class="pull-right"
>
<icon name="clock" />
{{ remainingTime(action) }}
</span>
</button> </button>
</li> </li>
</ul> </ul>
......
...@@ -59,6 +59,16 @@ export default { ...@@ -59,6 +59,16 @@ export default {
}; };
}, },
computed: { computed: {
actions() {
if (!this.pipeline || !this.pipeline.details) {
return [];
}
const { details } = this.pipeline;
return [
...(details.manual_actions || []),
...(details.scheduled_actions || []),
];
},
/** /**
* If provided, returns the commit tag. * If provided, returns the commit tag.
* Needed to render the commit component column. * Needed to render the commit component column.
...@@ -321,8 +331,8 @@ export default { ...@@ -321,8 +331,8 @@ export default {
> >
<div class="btn-group table-action-buttons"> <div class="btn-group table-action-buttons">
<pipelines-actions-component <pipelines-actions-component
v-if="pipeline.details.manual_actions.length" v-if="actions.length > 0"
:actions="pipeline.details.manual_actions" :actions="actions"
/> />
<pipelines-artifacts-component <pipelines-artifacts-component
......
...@@ -360,6 +360,10 @@ ...@@ -360,6 +360,10 @@
i { i {
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
svg {
fill: $gl-text-color-secondary;
}
} }
.clone-dropdown-btn a { .clone-dropdown-btn a {
......
...@@ -64,6 +64,7 @@ ...@@ -64,6 +64,7 @@
} }
} }
.ci-status-icon-scheduled,
.ci-status-icon-manual { .ci-status-icon-manual {
svg { svg {
fill: $gl-text-color; fill: $gl-text-color;
......
...@@ -760,6 +760,7 @@ ...@@ -760,6 +760,7 @@
} }
&.ci-status-icon-canceled, &.ci-status-icon-canceled,
&.ci-status-icon-scheduled,
&.ci-status-icon-disabled, &.ci-status-icon-disabled,
&.ci-status-icon-not-found, &.ci-status-icon-not-found,
&.ci-status-icon-manual { &.ci-status-icon-manual {
......
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
&.ci-canceled, &.ci-canceled,
&.ci-disabled, &.ci-disabled,
&.ci-scheduled,
&.ci-manual { &.ci-manual {
color: $gl-text-color; color: $gl-text-color;
border-color: $gl-text-color; border-color: $gl-text-color;
......
...@@ -110,6 +110,13 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -110,6 +110,13 @@ class Projects::JobsController < Projects::ApplicationController
redirect_to build_path(@build) redirect_to build_path(@build)
end end
def unschedule
return respond_422 unless @build.scheduled?
@build.unschedule!
redirect_to build_path(@build)
end
def status def status
render json: BuildSerializer render json: BuildSerializer
.new(project: @project, current_user: @current_user) .new(project: @project, current_user: @current_user)
......
...@@ -20,6 +20,8 @@ module CiStatusHelper ...@@ -20,6 +20,8 @@ module CiStatusHelper
'passed with warnings' 'passed with warnings'
when 'manual' when 'manual'
'waiting for manual action' 'waiting for manual action'
when 'scheduled'
'waiting for delayed job'
else else
status status
end end
...@@ -39,6 +41,8 @@ module CiStatusHelper ...@@ -39,6 +41,8 @@ module CiStatusHelper
s_('CiStatusText|passed') s_('CiStatusText|passed')
when 'manual' when 'manual'
s_('CiStatusText|blocked') s_('CiStatusText|blocked')
when 'scheduled'
s_('CiStatusText|scheduled')
else else
# All states are already being translated inside the detailed statuses: # All states are already being translated inside the detailed statuses:
# :running => Gitlab::Ci::Status::Running # :running => Gitlab::Ci::Status::Running
...@@ -83,6 +87,8 @@ module CiStatusHelper ...@@ -83,6 +87,8 @@ module CiStatusHelper
'status_skipped' 'status_skipped'
when 'manual' when 'manual'
'status_manual' 'status_manual'
when 'scheduled'
'status_scheduled'
else else
'status_canceled' 'status_canceled'
end end
......
...@@ -21,9 +21,17 @@ module TimeHelper ...@@ -21,9 +21,17 @@ module TimeHelper
"#{from.to_s(:short)} - #{to.to_s(:short)}" "#{from.to_s(:short)} - #{to.to_s(:short)}"
end end
def duration_in_numbers(duration) def duration_in_numbers(duration_in_seconds, allow_overflow = false)
time_format = duration < 1.hour ? "%M:%S" : "%H:%M:%S" if allow_overflow
seconds = duration_in_seconds % 1.minute
minutes = (duration_in_seconds / 1.minute) % (1.hour / 1.minute)
hours = duration_in_seconds / 1.hour
Time.at(duration).utc.strftime(time_format) "%02d:%02d:%02d" % [hours, minutes, seconds]
else
time_format = duration_in_seconds < 1.hour ? "%M:%S" : "%H:%M:%S"
Time.at(duration_in_seconds).utc.strftime(time_format)
end
end end
end end
...@@ -92,7 +92,8 @@ module Ci ...@@ -92,7 +92,8 @@ module Ci
scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
scope :scheduled_actions, ->() { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) }
scope :ref_protected, -> { where(protected: true) } scope :ref_protected, -> { where(protected: true) }
scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where('ci_builds.id = ci_build_trace_chunks.build_id').select(1)) } scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where('ci_builds.id = ci_build_trace_chunks.build_id').select(1)) }
...@@ -159,6 +160,34 @@ module Ci ...@@ -159,6 +160,34 @@ module Ci
transition created: :manual transition created: :manual
end end
event :schedule do
transition created: :scheduled
end
event :unschedule do
transition scheduled: :manual
end
event :enqueue_scheduled do
transition scheduled: :pending, if: ->(build) do
build.scheduled_at && build.scheduled_at < Time.now
end
end
before_transition scheduled: any do |build|
build.scheduled_at = nil
end
before_transition created: :scheduled do |build|
build.scheduled_at = build.options_scheduled_at
end
after_transition created: :scheduled do |build|
build.run_after_commit do
Ci::BuildScheduleWorker.perform_at(build.scheduled_at, build.id)
end
end
after_transition any => [:pending] do |build| after_transition any => [:pending] do |build|
build.run_after_commit do build.run_after_commit do
BuildQueueWorker.perform_async(id) BuildQueueWorker.perform_async(id)
...@@ -226,11 +255,20 @@ module Ci ...@@ -226,11 +255,20 @@ module Ci
end end
def playable? def playable?
action? && (manual? || retryable?) action? && (manual? || scheduled? || retryable?)
end
def schedulable?
Feature.enabled?('ci_enable_scheduled_build', default_enabled: true) &&
self.when == 'delayed' && options[:start_in].present?
end
def options_scheduled_at
ChronicDuration.parse(options[:start_in])&.seconds&.from_now
end end
def action? def action?
self.when == 'manual' %w[manual delayed].include?(self.when)
end end
# rubocop: disable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass
......
...@@ -35,6 +35,7 @@ module Ci ...@@ -35,6 +35,7 @@ module Ci
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus' has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus'
has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :artifacts, -> { latest.with_artifacts_not_expired.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' has_many :artifacts, -> { latest.with_artifacts_not_expired.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
...@@ -80,7 +81,7 @@ module Ci ...@@ -80,7 +81,7 @@ module Ci
state_machine :status, initial: :created do state_machine :status, initial: :created do
event :enqueue do event :enqueue do
transition [:created, :skipped] => :pending transition [:created, :skipped, :scheduled] => :pending
transition [:success, :failed, :canceled] => :running transition [:success, :failed, :canceled] => :running
end end
...@@ -108,6 +109,10 @@ module Ci ...@@ -108,6 +109,10 @@ module Ci
transition any - [:manual] => :manual transition any - [:manual] => :manual
end end
event :delay do
transition any - [:scheduled] => :scheduled
end
# IMPORTANT # IMPORTANT
# Do not add any operations to this state_machine # Do not add any operations to this state_machine
# Create a separate worker for each new operation # Create a separate worker for each new operation
...@@ -544,6 +549,7 @@ module Ci ...@@ -544,6 +549,7 @@ module Ci
when 'canceled' then cancel when 'canceled' then cancel
when 'skipped' then skip when 'skipped' then skip
when 'manual' then block when 'manual' then block
when 'scheduled' then delay
else else
raise HasStatus::UnknownStatusError, raise HasStatus::UnknownStatusError,
"Unknown status `#{latest_builds_status}`" "Unknown status `#{latest_builds_status}`"
......
...@@ -65,6 +65,10 @@ module Ci ...@@ -65,6 +65,10 @@ module Ci
event :block do event :block do
transition any - [:manual] => :manual transition any - [:manual] => :manual
end end
event :delay do
transition any - [:scheduled] => :scheduled
end
end end
def update_status def update_status
...@@ -77,6 +81,7 @@ module Ci ...@@ -77,6 +81,7 @@ module Ci
when 'failed' then drop when 'failed' then drop
when 'canceled' then cancel when 'canceled' then cancel
when 'manual' then block when 'manual' then block
when 'scheduled' then delay
when 'skipped', nil then skip when 'skipped', nil then skip
else else
raise HasStatus::UnknownStatusError, raise HasStatus::UnknownStatusError,
......
...@@ -49,7 +49,8 @@ class CommitStatus < ActiveRecord::Base ...@@ -49,7 +49,8 @@ class CommitStatus < ActiveRecord::Base
stuck_or_timeout_failure: 3, stuck_or_timeout_failure: 3,
runner_system_failure: 4, runner_system_failure: 4,
missing_dependency_failure: 5, missing_dependency_failure: 5,
runner_unsupported: 6 runner_unsupported: 6,
stale_schedule: 7
} }
## ##
...@@ -71,7 +72,7 @@ class CommitStatus < ActiveRecord::Base ...@@ -71,7 +72,7 @@ class CommitStatus < ActiveRecord::Base
end end
event :enqueue do event :enqueue do
transition [:created, :skipped, :manual] => :pending transition [:created, :skipped, :manual, :scheduled] => :pending
end end
event :run do event :run do
...@@ -83,7 +84,7 @@ class CommitStatus < ActiveRecord::Base ...@@ -83,7 +84,7 @@ class CommitStatus < ActiveRecord::Base
end end
event :drop do event :drop do
transition [:created, :pending, :running] => :failed transition [:created, :pending, :running, :scheduled] => :failed
end end
event :success do event :success do
...@@ -91,10 +92,10 @@ class CommitStatus < ActiveRecord::Base ...@@ -91,10 +92,10 @@ class CommitStatus < ActiveRecord::Base
end end
event :cancel do event :cancel do
transition [:created, :pending, :running, :manual] => :canceled transition [:created, :pending, :running, :manual, :scheduled] => :canceled
end end
before_transition [:created, :skipped, :manual] => :pending do |commit_status| before_transition [:created, :skipped, :manual, :scheduled] => :pending do |commit_status|
commit_status.queued_at = Time.now commit_status.queued_at = Time.now
end end
......
...@@ -4,14 +4,15 @@ module HasStatus ...@@ -4,14 +4,15 @@ module HasStatus
extend ActiveSupport::Concern extend ActiveSupport::Concern
DEFAULT_STATUS = 'created'.freeze DEFAULT_STATUS = 'created'.freeze
BLOCKED_STATUS = 'manual'.freeze BLOCKED_STATUS = %w[manual scheduled].freeze
AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped manual].freeze AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped manual scheduled].freeze
STARTED_STATUSES = %w[running success failed skipped manual].freeze STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
ACTIVE_STATUSES = %w[pending running].freeze ACTIVE_STATUSES = %w[pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
ORDERED_STATUSES = %w[failed pending running manual canceled success skipped created].freeze ORDERED_STATUSES = %w[failed pending running manual scheduled canceled success skipped created].freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7 }.freeze failed: 4, canceled: 5, skipped: 6, manual: 7,
scheduled: 8 }.freeze
UnknownStatusError = Class.new(StandardError) UnknownStatusError = Class.new(StandardError)
...@@ -24,6 +25,7 @@ module HasStatus ...@@ -24,6 +25,7 @@ module HasStatus
created = scope_relevant.created.select('count(*)').to_sql created = scope_relevant.created.select('count(*)').to_sql
success = scope_relevant.success.select('count(*)').to_sql success = scope_relevant.success.select('count(*)').to_sql
manual = scope_relevant.manual.select('count(*)').to_sql manual = scope_relevant.manual.select('count(*)').to_sql
scheduled = scope_relevant.scheduled.select('count(*)').to_sql
pending = scope_relevant.pending.select('count(*)').to_sql pending = scope_relevant.pending.select('count(*)').to_sql
running = scope_relevant.running.select('count(*)').to_sql running = scope_relevant.running.select('count(*)').to_sql
skipped = scope_relevant.skipped.select('count(*)').to_sql skipped = scope_relevant.skipped.select('count(*)').to_sql
...@@ -40,6 +42,7 @@ module HasStatus ...@@ -40,6 +42,7 @@ module HasStatus
WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending'
WHEN (#{running})+(#{pending})>0 THEN 'running' WHEN (#{running})+(#{pending})>0 THEN 'running'
WHEN (#{manual})>0 THEN 'manual' WHEN (#{manual})>0 THEN 'manual'
WHEN (#{scheduled})>0 THEN 'scheduled'
WHEN (#{created})>0 THEN 'running' WHEN (#{created})>0 THEN 'running'
ELSE 'failed' ELSE 'failed'
END)" END)"
...@@ -74,6 +77,7 @@ module HasStatus ...@@ -74,6 +77,7 @@ module HasStatus
state :canceled, value: 'canceled' state :canceled, value: 'canceled'
state :skipped, value: 'skipped' state :skipped, value: 'skipped'
state :manual, value: 'manual' state :manual, value: 'manual'
state :scheduled, value: 'scheduled'
end end
scope :created, -> { where(status: 'created') } scope :created, -> { where(status: 'created') }
...@@ -85,6 +89,7 @@ module HasStatus ...@@ -85,6 +89,7 @@ module HasStatus
scope :canceled, -> { where(status: 'canceled') } scope :canceled, -> { where(status: 'canceled') }
scope :skipped, -> { where(status: 'skipped') } scope :skipped, -> { where(status: 'skipped') }
scope :manual, -> { where(status: 'manual') } scope :manual, -> { where(status: 'manual') }
scope :scheduled, -> { where(status: 'scheduled') }
scope :alive, -> { where(status: [:created, :pending, :running]) } scope :alive, -> { where(status: [:created, :pending, :running]) }
scope :created_or_pending, -> { where(status: [:created, :pending]) } scope :created_or_pending, -> { where(status: [:created, :pending]) }
scope :running_or_pending, -> { where(status: [:running, :pending]) } scope :running_or_pending, -> { where(status: [:running, :pending]) }
...@@ -92,7 +97,7 @@ module HasStatus ...@@ -92,7 +97,7 @@ module HasStatus
scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) } scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) }
scope :cancelable, -> do scope :cancelable, -> do
where(status: [:running, :pending, :created]) where(status: [:running, :pending, :created, :scheduled])
end end
end end
...@@ -109,7 +114,7 @@ module HasStatus ...@@ -109,7 +114,7 @@ module HasStatus
end end
def blocked? def blocked?
BLOCKED_STATUS == status BLOCKED_STATUS.include?(status)
end end
private private
......
...@@ -35,6 +35,10 @@ module Ci ...@@ -35,6 +35,10 @@ module Ci
"#{subject.name} - #{detailed_status.status_tooltip}" "#{subject.name} - #{detailed_status.status_tooltip}"
end end
def execute_in
scheduled? && scheduled_at && [0, scheduled_at - Time.now].max
end
private private
def tooltip_for_badge def tooltip_for_badge
......
...@@ -8,7 +8,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated ...@@ -8,7 +8,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
stuck_or_timeout_failure: 'There has been a timeout failure or the job got stuck. Check your timeout limits or try again', stuck_or_timeout_failure: 'There has been a timeout failure or the job got stuck. Check your timeout limits or try again',
runner_system_failure: 'There has been a runner system failure, please try again', runner_system_failure: 'There has been a runner system failure, please try again',
missing_dependency_failure: 'There has been a missing dependency failure', missing_dependency_failure: 'There has been a missing dependency failure',
runner_unsupported: 'Your runner is outdated, please upgrade your runner' runner_unsupported: 'Your runner is outdated, please upgrade your runner',
stale_schedule: 'Delayed job could not be executed by some reason, please try again'
}.freeze }.freeze
private_constant :CALLOUT_FAILURE_MESSAGES private_constant :CALLOUT_FAILURE_MESSAGES
......
...@@ -12,6 +12,11 @@ class BuildActionEntity < Grape::Entity ...@@ -12,6 +12,11 @@ class BuildActionEntity < Grape::Entity
end end
expose :playable?, as: :playable expose :playable?, as: :playable
expose :scheduled_at, if: -> (build) { build.scheduled? }
expose :unschedule_path, if: -> (build) { build.scheduled? } do |build|
unschedule_project_job_path(build.project, build)
end
private private
......
...@@ -24,7 +24,12 @@ class JobEntity < Grape::Entity ...@@ -24,7 +24,12 @@ class JobEntity < Grape::Entity
path_to(:play_namespace_project_job, build) path_to(:play_namespace_project_job, build)
end end
expose :unschedule_path, if: -> (*) { scheduled? } do |build|
path_to(:unschedule_namespace_project_job, build)
end
expose :playable?, as: :playable expose :playable?, as: :playable
expose :scheduled_at, if: -> (*) { scheduled? }
expose :created_at expose :created_at
expose :updated_at expose :updated_at
expose :detailed_status, as: :status, with: DetailedStatusEntity expose :detailed_status, as: :status, with: DetailedStatusEntity
...@@ -47,6 +52,10 @@ class JobEntity < Grape::Entity ...@@ -47,6 +52,10 @@ class JobEntity < Grape::Entity
build.playable? && can?(request.current_user, :update_build, build) build.playable? && can?(request.current_user, :update_build, build)
end end
def scheduled?
build.scheduled?
end
def detailed_status def detailed_status
build.detailed_status(request.current_user) build.detailed_status(request.current_user)
end end
......
...@@ -5,5 +5,6 @@ class PipelineDetailsEntity < PipelineEntity ...@@ -5,5 +5,6 @@ class PipelineDetailsEntity < PipelineEntity
expose :ordered_stages, as: :stages, using: StageEntity expose :ordered_stages, as: :stages, using: StageEntity
expose :artifacts, using: BuildArtifactEntity expose :artifacts, using: BuildArtifactEntity
expose :manual_actions, using: BuildActionEntity expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
end end
end end
...@@ -13,6 +13,7 @@ class PipelineSerializer < BaseSerializer ...@@ -13,6 +13,7 @@ class PipelineSerializer < BaseSerializer
:cancelable_statuses, :cancelable_statuses,
:trigger_requests, :trigger_requests,
:manual_actions, :manual_actions,
:scheduled_actions,
:artifacts, :artifacts,
{ {
pending_builds: :project, pending_builds: :project,
......
# frozen_string_literal: true
module Ci
class EnqueueBuildService < BaseService
def execute(build)
build.enqueue
end
end
end
# frozen_string_literal: true
module Ci
class ProcessBuildService < BaseService
def execute(build, current_status)
if valid_statuses_for_when(build.when).include?(current_status)
if build.schedulable?
build.schedule
elsif build.action?
build.actionize
else
enqueue(build)
end
true
else
build.skip
false
end
end
private
def enqueue(build)
build.enqueue
end
def valid_statuses_for_when(value)
case value
when 'on_success'
%w[success skipped]
when 'on_failure'
%w[failed]
when 'always'
%w[success failed skipped]
when 'manual'
%w[success skipped]
when 'delayed'
%w[success skipped]
else
[]
end
end
end
end
...@@ -24,42 +24,18 @@ module Ci ...@@ -24,42 +24,18 @@ module Ci
def process_stage(index) def process_stage(index)
current_status = status_for_prior_stages(index) current_status = status_for_prior_stages(index)
return if HasStatus::BLOCKED_STATUS == current_status return if HasStatus::BLOCKED_STATUS.include?(current_status)
if HasStatus::COMPLETED_STATUSES.include?(current_status) if HasStatus::COMPLETED_STATUSES.include?(current_status)
created_builds_in_stage(index).select do |build| created_builds_in_stage(index).select do |build|
Gitlab::OptimisticLocking.retry_lock(build) do |subject| Gitlab::OptimisticLocking.retry_lock(build) do |subject|
process_build(subject, current_status) Ci::ProcessBuildService.new(project, @user)
.execute(build, current_status)
end end
end end
end end
end end
def process_build(build, current_status)
if valid_statuses_for_when(build.when).include?(current_status)
build.action? ? build.actionize : enqueue_build(build)
true
else
build.skip
false
end
end
def valid_statuses_for_when(value)
case value
when 'on_success'
%w[success skipped]
when 'on_failure'
%w[failed]
when 'always'
%w[success failed skipped]
when 'manual'
%w[success skipped]
else
[]
end
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def status_for_prior_stages(index) def status_for_prior_stages(index)
pipeline.builds.where('stage_idx < ?', index).latest.status || 'success' pipeline.builds.where('stage_idx < ?', index).latest.status || 'success'
...@@ -101,9 +77,5 @@ module Ci ...@@ -101,9 +77,5 @@ module Ci
.update_all(retried: true) if latest_statuses.any? .update_all(retried: true) if latest_statuses.any?
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def enqueue_build(build)
Ci::EnqueueBuildService.new(project, @user).execute(build)
end
end end
end end
# frozen_string_literal: true
module Ci
class RunScheduledBuildService < ::BaseService
def execute(build)
unless can?(current_user, :update_build, build)
raise Gitlab::Access::AccessDeniedError
end
build.enqueue_scheduled!
end
end
end
...@@ -47,7 +47,9 @@ ...@@ -47,7 +47,9 @@
%span.badge.badge-info triggered %span.badge.badge-info triggered
- if job.try(:allow_failure) - if job.try(:allow_failure)
%span.badge.badge-danger allowed to fail %span.badge.badge-danger allowed to fail
- if job.action? - if job.schedulable?
%span.badge.badge-info= s_('DelayedJobs|scheduled')
- elsif job.action?
%span.badge.badge-info manual %span.badge.badge-info manual
- if pipeline_link - if pipeline_link
...@@ -101,6 +103,24 @@ ...@@ -101,6 +103,24 @@
- if job.active? - if job.active?
= link_to cancel_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = link_to cancel_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
= icon('remove', class: 'cred') = icon('remove', class: 'cred')
- elsif job.scheduled?
.btn-group
.btn.btn-default.has-tooltip{ disabled: true,
title: job.scheduled_at }
= sprite_icon('planning')
= duration_in_numbers(job.execute_in, true)
- confirmation_message = s_("DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes.") % { job_name: job.name }
= link_to play_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Start now'),
class: 'btn btn-default btn-build has-tooltip',
data: { confirm: confirmation_message } do
= sprite_icon('play')
= link_to unschedule_project_job_path(job.project, job, return_to: request.original_url),
method: :post,
title: s_('DelayedJobs|Unschedule'),
class: 'btn btn-default btn-build has-tooltip' do
= sprite_icon('time-out')
- elsif allow_retry - elsif allow_retry
- if job.playable? && !admin && can?(current_user, :update_build, job) - if job.playable? && !admin && can?(current_user, :update_build, job)
= link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
......
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><circle cx="7" cy="7" r="7"/><circle fill="#FFF" cx="7" cy="7" r="6"/><g transform="translate(2.75 2.75)" fill-rule="nonzero"><path d="M4.165 7.81a3.644 3.644 0 1 1 0-7.29 3.644 3.644 0 0 1 0 7.29zm0-1.042a2.603 2.603 0 1 0 0-5.206 2.603 2.603 0 0 0 0 5.206z"/><rect x="3.644" y="2.083" width="1.041" height="2.603" rx=".488"/><rect x="3.644" y="3.644" width="2.083" height="1.041" rx=".488"/></g></svg>
\ No newline at end of file
<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M6.16 11.55a5.39 5.39 0 1 1 0-10.78 5.39 5.39 0 0 1 0 10.78zm0-1.54a3.85 3.85 0 1 0 0-7.7 3.85 3.85 0 0 0 0 7.7z"/><rect x="5.39" y="3.08" width="1.54" height="3.85" rx=".767"/><rect x="5.39" y="5.39" width="3.08" height="1.54" rx=".767"/></svg>
\ No newline at end of file
...@@ -70,6 +70,7 @@ ...@@ -70,6 +70,7 @@
- pipeline_processing:pipeline_update - pipeline_processing:pipeline_update
- pipeline_processing:stage_update - pipeline_processing:stage_update
- pipeline_processing:update_head_pipeline_for_merge_request - pipeline_processing:update_head_pipeline_for_merge_request
- pipeline_processing:ci_build_schedule
- repository_check:repository_check_clear - repository_check:repository_check_clear
- repository_check:repository_check_batch - repository_check:repository_check_batch
......
# frozen_string_literal: true
module Ci
class BuildScheduleWorker
include ApplicationWorker
include PipelineQueue
queue_namespace :pipeline_processing
def perform(build_id)
::Ci::Build.find_by_id(build_id).try do |build|
break unless build.scheduled?
Ci::RunScheduledBuildService
.new(build.project, build.user).execute(build)
end
end
end
end
...@@ -8,6 +8,7 @@ class StuckCiJobsWorker ...@@ -8,6 +8,7 @@ class StuckCiJobsWorker
BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour
BUILD_PENDING_OUTDATED_TIMEOUT = 1.day BUILD_PENDING_OUTDATED_TIMEOUT = 1.day
BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour
BUILD_PENDING_STUCK_TIMEOUT = 1.hour BUILD_PENDING_STUCK_TIMEOUT = 1.hour
def perform def perform
...@@ -15,9 +16,10 @@ class StuckCiJobsWorker ...@@ -15,9 +16,10 @@ class StuckCiJobsWorker
Rails.logger.info "#{self.class}: Cleaning stuck builds" Rails.logger.info "#{self.class}: Cleaning stuck builds"
drop :running, BUILD_RUNNING_OUTDATED_TIMEOUT drop :running, BUILD_RUNNING_OUTDATED_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure
drop :pending, BUILD_PENDING_OUTDATED_TIMEOUT drop :pending, BUILD_PENDING_OUTDATED_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure
drop_stuck :pending, BUILD_PENDING_STUCK_TIMEOUT drop :scheduled, BUILD_SCHEDULED_OUTDATED_TIMEOUT, 'scheduled_at IS NOT NULL AND scheduled_at < ?', :stale_schedule
drop_stuck :pending, BUILD_PENDING_STUCK_TIMEOUT, 'ci_builds.updated_at < ?', :stuck_or_timeout_failure
remove_lease remove_lease
end end
...@@ -32,25 +34,25 @@ class StuckCiJobsWorker ...@@ -32,25 +34,25 @@ class StuckCiJobsWorker
Gitlab::ExclusiveLease.cancel(EXCLUSIVE_LEASE_KEY, @uuid) Gitlab::ExclusiveLease.cancel(EXCLUSIVE_LEASE_KEY, @uuid)
end end
def drop(status, timeout) def drop(status, timeout, condition, reason)
search(status, timeout) do |build| search(status, timeout, condition) do |build|
drop_build :outdated, build, status, timeout drop_build :outdated, build, status, timeout, reason
end end
end end
def drop_stuck(status, timeout) def drop_stuck(status, timeout, condition, reason)
search(status, timeout) do |build| search(status, timeout, condition) do |build|
break unless build.stuck? break unless build.stuck?
drop_build :stuck, build, status, timeout drop_build :stuck, build, status, timeout, reason
end end
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def search(status, timeout) def search(status, timeout, condition)
loop do loop do
jobs = Ci::Build.where(status: status) jobs = Ci::Build.where(status: status)
.where('ci_builds.updated_at < ?', timeout.ago) .where(condition, timeout.ago)
.includes(:tags, :runner, project: :namespace) .includes(:tags, :runner, project: :namespace)
.limit(100) .limit(100)
.to_a .to_a
...@@ -63,10 +65,10 @@ class StuckCiJobsWorker ...@@ -63,10 +65,10 @@ class StuckCiJobsWorker
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def drop_build(type, build, status, timeout) def drop_build(type, build, status, timeout, reason)
Rails.logger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout})" Rails.logger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout}, reason: #{reason})"
Gitlab::OptimisticLocking.retry_lock(build, 3) do |b| Gitlab::OptimisticLocking.retry_lock(build, 3) do |b|
b.drop(:stuck_or_timeout_failure) b.drop(reason)
end end
end end
end end
---
title: Allow pipelines to schedule delayed job runs
merge_request: 21767
author:
type: added
...@@ -276,6 +276,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -276,6 +276,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
member do member do
get :status get :status
post :cancel post :cancel
post :unschedule
post :retry post :retry
post :play post :play
post :erase post :erase
......
# frozen_string_literal: true
class AddScheduledAtToCiBuilds < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :ci_builds, :scheduled_at, :datetime_with_timezone
end
end
# frozen_string_literal: true
class AddPartialIndexToScheduledAt < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'partial_index_ci_builds_on_scheduled_at_with_scheduled_jobs'.freeze
disable_ddl_transaction!
def up
add_concurrent_index(:ci_builds, :scheduled_at, where: "scheduled_at IS NOT NULL AND type = 'Ci::Build' AND status = 'scheduled'", name: INDEX_NAME)
end
def down
remove_concurrent_index_by_name(:ci_builds, INDEX_NAME)
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180924141949) do ActiveRecord::Schema.define(version: 20180924201039) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -334,6 +334,7 @@ ActiveRecord::Schema.define(version: 20180924141949) do ...@@ -334,6 +334,7 @@ ActiveRecord::Schema.define(version: 20180924141949) do
t.integer "artifacts_metadata_store" t.integer "artifacts_metadata_store"
t.boolean "protected" t.boolean "protected"
t.integer "failure_reason" t.integer "failure_reason"
t.datetime_with_timezone "scheduled_at"
end end
add_index "ci_builds", ["artifacts_expire_at"], name: "index_ci_builds_on_artifacts_expire_at", where: "(artifacts_file <> ''::text)", using: :btree add_index "ci_builds", ["artifacts_expire_at"], name: "index_ci_builds_on_artifacts_expire_at", where: "(artifacts_file <> ''::text)", using: :btree
...@@ -346,6 +347,7 @@ ActiveRecord::Schema.define(version: 20180924141949) do ...@@ -346,6 +347,7 @@ ActiveRecord::Schema.define(version: 20180924141949) do
add_index "ci_builds", ["project_id", "id"], name: "index_ci_builds_on_project_id_and_id", using: :btree add_index "ci_builds", ["project_id", "id"], name: "index_ci_builds_on_project_id_and_id", using: :btree
add_index "ci_builds", ["protected"], name: "index_ci_builds_on_protected", using: :btree add_index "ci_builds", ["protected"], name: "index_ci_builds_on_protected", using: :btree
add_index "ci_builds", ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree add_index "ci_builds", ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree
add_index "ci_builds", ["scheduled_at"], name: "partial_index_ci_builds_on_scheduled_at_with_scheduled_jobs", where: "((scheduled_at IS NOT NULL) AND ((type)::text = 'Ci::Build'::text) AND ((status)::text = 'scheduled'::text))", using: :btree
add_index "ci_builds", ["stage_id", "stage_idx"], name: "tmp_build_stage_position_index", where: "(stage_idx IS NOT NULL)", using: :btree add_index "ci_builds", ["stage_id", "stage_idx"], name: "tmp_build_stage_position_index", where: "(stage_idx IS NOT NULL)", using: :btree
add_index "ci_builds", ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree add_index "ci_builds", ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree
add_index "ci_builds", ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree add_index "ci_builds", ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree
......
...@@ -151,7 +151,7 @@ module API ...@@ -151,7 +151,7 @@ module API
present build, with: Entities::Job present build, with: Entities::Job
end end
desc 'Trigger a manual job' do desc 'Trigger a actionable job (manual, scheduled, etc)' do
success Entities::Job success Entities::Job
detail 'This feature was added in GitLab 8.11' detail 'This feature was added in GitLab 8.11'
end end
......
...@@ -10,7 +10,7 @@ module Gitlab ...@@ -10,7 +10,7 @@ module Gitlab
include Attributable include Attributable
ALLOWED_KEYS = %i[tags script only except type image services ALLOWED_KEYS = %i[tags script only except type image services
allow_failure type stage when artifacts cache allow_failure type stage when start_in artifacts cache
dependencies before_script after_script variables dependencies before_script after_script variables
environment coverage retry extends].freeze environment coverage retry extends].freeze
...@@ -28,13 +28,16 @@ module Gitlab ...@@ -28,13 +28,16 @@ module Gitlab
greater_than_or_equal_to: 0, greater_than_or_equal_to: 0,
less_than_or_equal_to: 2 } less_than_or_equal_to: 2 }
validates :when, validates :when,
inclusion: { in: %w[on_success on_failure always manual], inclusion: { in: %w[on_success on_failure always manual delayed],
message: 'should be on_success, on_failure, ' \ message: 'should be on_success, on_failure, ' \
'always or manual' } 'always, manual or delayed' }
validates :dependencies, array_of_strings: true validates :dependencies, array_of_strings: true
validates :extends, type: String validates :extends, type: String
end end
validates :start_in, duration: { limit: '1 day' }, if: :delayed?
validates :start_in, absence: true, unless: :delayed?
end end
entry :before_script, Entry::Script, entry :before_script, Entry::Script,
...@@ -84,7 +87,7 @@ module Gitlab ...@@ -84,7 +87,7 @@ module Gitlab
:artifacts, :commands, :environment, :coverage, :retry :artifacts, :commands, :environment, :coverage, :retry
attributes :script, :tags, :allow_failure, :when, :dependencies, attributes :script, :tags, :allow_failure, :when, :dependencies,
:retry, :extends :retry, :extends, :start_in
def compose!(deps = nil) def compose!(deps = nil)
super do super do
...@@ -114,6 +117,10 @@ module Gitlab ...@@ -114,6 +117,10 @@ module Gitlab
self.when == 'manual' self.when == 'manual'
end end
def delayed?
self.when == 'delayed'
end
def ignored? def ignored?
allow_failure.nil? ? manual_action? : allow_failure allow_failure.nil? ? manual_action? : allow_failure
end end
......
...@@ -11,6 +11,15 @@ module Gitlab ...@@ -11,6 +11,15 @@ module Gitlab
false false
end end
def validate_duration_limit(value, limit)
return false unless value.is_a?(String)
ChronicDuration.parse(value).second.from_now <
ChronicDuration.parse(limit).second.from_now
rescue ChronicDuration::DurationParseError
false
end
def validate_array_of_strings(values) def validate_array_of_strings(values)
values.is_a?(Array) && values.all? { |value| validate_string(value) } values.is_a?(Array) && values.all? { |value| validate_string(value) }
end end
......
...@@ -49,6 +49,12 @@ module Gitlab ...@@ -49,6 +49,12 @@ module Gitlab
unless validate_duration(value) unless validate_duration(value)
record.errors.add(attribute, 'should be a duration') record.errors.add(attribute, 'should be a duration')
end end
if options[:limit]
unless validate_duration_limit(value, options[:limit])
record.errors.add(attribute, 'should not exceed the limit')
end
end
end end
end end
......
...@@ -5,6 +5,7 @@ module Gitlab ...@@ -5,6 +5,7 @@ module Gitlab
class Factory < Status::Factory class Factory < Status::Factory
def self.extended_statuses def self.extended_statuses
[[Status::Build::Erased, [[Status::Build::Erased,
Status::Build::Scheduled,
Status::Build::Manual, Status::Build::Manual,
Status::Build::Canceled, Status::Build::Canceled,
Status::Build::Created, Status::Build::Created,
...@@ -14,6 +15,7 @@ module Gitlab ...@@ -14,6 +15,7 @@ module Gitlab
Status::Build::Retryable], Status::Build::Retryable],
[Status::Build::Failed], [Status::Build::Failed],
[Status::Build::FailedAllowed, [Status::Build::FailedAllowed,
Status::Build::Unschedule,
Status::Build::Play, Status::Build::Play,
Status::Build::Stop], Status::Build::Stop],
[Status::Build::Action], [Status::Build::Action],
......
...@@ -10,7 +10,8 @@ module Gitlab ...@@ -10,7 +10,8 @@ module Gitlab
stuck_or_timeout_failure: 'stuck or timeout failure', stuck_or_timeout_failure: 'stuck or timeout failure',
runner_system_failure: 'runner system failure', runner_system_failure: 'runner system failure',
missing_dependency_failure: 'missing dependency failure', missing_dependency_failure: 'missing dependency failure',
runner_unsupported: 'unsupported runner' runner_unsupported: 'unsupported runner',
stale_schedule: 'stale schedule'
}.freeze }.freeze
private_constant :REASONS private_constant :REASONS
......
module Gitlab
module Ci
module Status
module Build
class Scheduled < Status::Extended
def illustration
{
image: 'illustrations/illustrations_scheduled-job_countdown.svg',
size: 'svg-394',
title: _("This is a scheduled to run in ") + " #{execute_in}",
content: _("This job will automatically run after it's timer finishes. " \
"Often they are used for incremental roll-out deploys " \
"to production environments. When unscheduled it converts " \
"into a manual action.")
}
end
def status_tooltip
"scheduled manual action (#{execute_in})"
end
def self.matches?(build, user)
build.scheduled? && build.scheduled_at
end
private
include TimeHelper
def execute_in
remaining_seconds = [0, subject.scheduled_at - Time.now].max
duration_in_numbers(remaining_seconds, true)
end
end
end
end
end
end
module Gitlab
module Ci
module Status
module Build
class Unschedule < Status::Extended
def label
'unschedule action'
end
def has_action?
can?(user, :update_build, subject)
end
def action_icon
'time-out'
end
def action_title
'Unschedule'
end
def action_button_title
_('Unschedule job')
end
def action_path
unschedule_project_job_path(subject.project, subject)
end
def action_method
:post
end
def self.matches?(build, user)
build.scheduled?
end
end
end
end
end
end
...@@ -5,6 +5,7 @@ module Gitlab ...@@ -5,6 +5,7 @@ module Gitlab
class Factory < Status::Factory class Factory < Status::Factory
def self.extended_statuses def self.extended_statuses
[[Status::SuccessWarning, [[Status::SuccessWarning,
Status::Pipeline::Scheduled,
Status::Pipeline::Blocked]] Status::Pipeline::Blocked]]
end end
......
module Gitlab
module Ci
module Status
module Pipeline
class Scheduled < Status::Extended
def text
s_('CiStatusText|scheduled')
end
def label
s_('CiStatusLabel|waiting for delayed job')
end
def self.matches?(pipeline, user)
pipeline.scheduled?
end
end
end
end
end
end
module Gitlab
module Ci
module Status
class Scheduled < Status::Core
def text
s_('CiStatusText|scheduled')
end
def label
s_('CiStatusLabel|scheduled')
end
def icon
'status_scheduled'
end
def favicon
'favicon_status_scheduled'
end
end
end
end
end
...@@ -49,7 +49,8 @@ module Gitlab ...@@ -49,7 +49,8 @@ module Gitlab
script: job[:script], script: job[:script],
after_script: job[:after_script], after_script: job[:after_script],
environment: job[:environment], environment: job[:environment],
retry: job[:retry] retry: job[:retry],
start_in: job[:start_in]
}.compact } }.compact }
end end
......
...@@ -1225,9 +1225,15 @@ msgstr "" ...@@ -1225,9 +1225,15 @@ msgstr ""
msgid "CiStatusLabel|pending" msgid "CiStatusLabel|pending"
msgstr "" msgstr ""
msgid "CiStatusLabel|scheduled"
msgstr ""
msgid "CiStatusLabel|skipped" msgid "CiStatusLabel|skipped"
msgstr "" msgstr ""
msgid "CiStatusLabel|waiting for delayed job"
msgstr ""
msgid "CiStatusLabel|waiting for manual action" msgid "CiStatusLabel|waiting for manual action"
msgstr "" msgstr ""
...@@ -1252,6 +1258,9 @@ msgstr "" ...@@ -1252,6 +1258,9 @@ msgstr ""
msgid "CiStatusText|pending" msgid "CiStatusText|pending"
msgstr "" msgstr ""
msgid "CiStatusText|scheduled"
msgstr ""
msgid "CiStatusText|skipped" msgid "CiStatusText|skipped"
msgstr "" msgstr ""
...@@ -2150,6 +2159,21 @@ msgstr "" ...@@ -2150,6 +2159,21 @@ msgstr ""
msgid "Define a custom pattern with cron syntax" msgid "Define a custom pattern with cron syntax"
msgstr "" msgstr ""
msgid "DelayedJobs|Are you sure you want to run %{jobName} immediately? This job will run automatically after it's timer finishes."
msgstr ""
msgid "DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after it's timer finishes."
msgstr ""
msgid "DelayedJobs|Start now"
msgstr ""
msgid "DelayedJobs|Unschedule"
msgstr ""
msgid "DelayedJobs|scheduled"
msgstr ""
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
...@@ -6103,6 +6127,9 @@ msgstr "" ...@@ -6103,6 +6127,9 @@ msgstr ""
msgid "This is a confidential issue." msgid "This is a confidential issue."
msgstr "" msgstr ""
msgid "This is a scheduled to run in "
msgstr ""
msgid "This is the author's first Merge Request to this project." msgid "This is the author's first Merge Request to this project."
msgstr "" msgstr ""
...@@ -6163,6 +6190,9 @@ msgstr "" ...@@ -6163,6 +6190,9 @@ msgstr ""
msgid "This job requires a manual action" msgid "This job requires a manual action"
msgstr "" msgstr ""
msgid "This job will automatically run after it's timer finishes. Often they are used for incremental roll-out deploys to production environments. When unscheduled it converts into a manual action."
msgstr ""
msgid "This means you can not push code until you create an empty repository or import existing one." msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr "" msgstr ""
...@@ -6518,6 +6548,9 @@ msgstr "" ...@@ -6518,6 +6548,9 @@ msgstr ""
msgid "Unresolve discussion" msgid "Unresolve discussion"
msgstr "" msgstr ""
msgid "Unschedule job"
msgstr ""
msgid "Unstage" msgid "Unstage"
msgstr "" msgstr ""
......
...@@ -631,6 +631,46 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do ...@@ -631,6 +631,46 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
end end
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 describe 'POST cancel_all' do
before do before do
project.add_developer(user) project.add_developer(user)
......
...@@ -70,6 +70,18 @@ FactoryBot.define do ...@@ -70,6 +70,18 @@ FactoryBot.define do
status 'created' status 'created'
end 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 trait :manual do
status 'manual' status 'manual'
self.when 'manual' self.when 'manual'
...@@ -98,6 +110,15 @@ FactoryBot.define do ...@@ -98,6 +110,15 @@ FactoryBot.define do
success success
end end
trait :schedulable do
self.when 'delayed'
options start_in: '1 minute'
end
trait :actionable do
self.when 'manual'
end
trait :retried do trait :retried do
retried true retried true
end end
......
...@@ -54,6 +54,10 @@ FactoryBot.define do ...@@ -54,6 +54,10 @@ FactoryBot.define do
status :manual status :manual
end end
trait :scheduled do
status :scheduled
end
trait :success do trait :success do
status :success status :success
end end
......
...@@ -41,6 +41,10 @@ FactoryBot.define do ...@@ -41,6 +41,10 @@ FactoryBot.define do
status 'manual' status 'manual'
end end
trait :scheduled do
status 'scheduled'
end
after(:build) do |build, evaluator| after(:build) do |build, evaluator|
build.project = build.pipeline.project build.project = build.pipeline.project
end end
......
...@@ -559,6 +559,34 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do ...@@ -559,6 +559,34 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do
end end
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 context 'Non triggered job' do
let(:job) { create(:ci_build, :created, pipeline: pipeline) } let(:job) { create(:ci_build, :created, pipeline: pipeline) }
......
...@@ -31,6 +31,11 @@ describe 'Pipeline', :js do ...@@ -31,6 +31,11 @@ describe 'Pipeline', :js do
pipeline: pipeline, stage: 'deploy', name: 'manual-build') pipeline: pipeline, stage: 'deploy', name: 'manual-build')
end end
let!(:build_scheduled) do
create(:ci_build, :scheduled,
pipeline: pipeline, stage: 'deploy', name: 'delayed-job')
end
let!(:build_external) do let!(:build_external) do
create(:generic_commit_status, status: 'success', create(:generic_commit_status, status: 'success',
pipeline: pipeline, pipeline: pipeline,
...@@ -79,10 +84,12 @@ describe 'Pipeline', :js do ...@@ -79,10 +84,12 @@ describe 'Pipeline', :js do
end end
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 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
end end
...@@ -105,6 +112,27 @@ describe 'Pipeline', :js do ...@@ -105,6 +112,27 @@ describe 'Pipeline', :js do
end end
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 context 'when pipeline has failed builds' do
it 'shows the failed icon and a retry action for the failed build' do it 'shows the failed icon and a retry action for the failed build' do
page.within('#ci-badge-test') do page.within('#ci-badge-test') do
...@@ -315,6 +343,18 @@ describe 'Pipeline', :js do ...@@ -315,6 +343,18 @@ describe 'Pipeline', :js do
it { expect(build_manual.reload).to be_pending } it { expect(build_manual.reload).to be_pending }
end 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 context 'failed jobs' do
it 'displays a tooltip with the failure reason' do it 'displays a tooltip with the failure reason' do
page.within('.ci-table') do page.within('.ci-table') do
......
...@@ -232,6 +232,60 @@ describe 'Pipelines', :js do ...@@ -232,6 +232,60 @@ describe 'Pipelines', :js do
end end
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 'for generic statuses' do
context 'when running' do context 'when running' do
let!(:running) do let!(:running) do
......
...@@ -20,17 +20,35 @@ describe TimeHelper do ...@@ -20,17 +20,35 @@ describe TimeHelper do
end end
describe "#duration_in_numbers" do describe "#duration_in_numbers" do
it "returns minutes and seconds" do using RSpec::Parameterized::TableSyntax
durations_and_expectations = {
100 => "01:40", context "without passing allow_overflow" do
121 => "02:01", where(:duration, :formatted_string) do
3721 => "01:02:01", 0 | "00:00"
0 => "00:00", 1.second | "00:01"
42 => "00:42" 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| with_them do
expect(duration_in_numbers(duration)).to eq(expectation) it { expect(duration_in_numbers(duration, true)).to eq formatted_string }
end end
end end
end end
......
...@@ -6,9 +6,7 @@ describe('Date time utils', () => { ...@@ -6,9 +6,7 @@ describe('Date time utils', () => {
const date = new Date(); const date = new Date();
date.setFullYear(date.getFullYear() - 1); date.setFullYear(date.getFullYear() - 1);
expect( expect(datetimeUtility.timeFor(date)).toBe('Past due');
datetimeUtility.timeFor(date),
).toBe('Past due');
}); });
it('returns remaining time when in the future', () => { it('returns remaining time when in the future', () => {
...@@ -19,9 +17,7 @@ describe('Date time utils', () => { ...@@ -19,9 +17,7 @@ describe('Date time utils', () => {
// short of a full year, timeFor will return '11 months remaining' // short of a full year, timeFor will return '11 months remaining'
date.setDate(date.getDate() + 1); date.setDate(date.getDate() + 1);
expect( expect(datetimeUtility.timeFor(date)).toBe('1 year remaining');
datetimeUtility.timeFor(date),
).toBe('1 year remaining');
}); });
}); });
...@@ -168,3 +164,20 @@ describe('getTimeframeWindowFrom', () => { ...@@ -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);
});
});
});
import Vue from 'vue'; 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', () => { describe('Pipelines Actions dropdown', () => {
let component; const Component = Vue.extend(PipelinesActions);
let actions; let vm;
let ActionsComponent;
beforeEach(() => { afterEach(() => {
ActionsComponent = Vue.extend(pipelinesActionsComp); vm.$destroy();
});
actions = [ describe('manual actions', () => {
const actions = [
{ {
name: 'stop_review', name: 'stop_review',
path: '/root/review-app/builds/1893/play', path: `${TEST_HOST}/root/review-app/builds/1893/play`,
}, },
{ {
name: 'foo', name: 'foo',
path: '#', path: `${TEST_HOST}/disabled/pipeline/action`,
playable: false, playable: false,
}, },
]; ];
component = new ActionsComponent({ beforeEach(() => {
propsData: { vm = mountComponent(Component, { actions });
actions, });
},
}).$mount();
});
it('should render a dropdown with the provided actions', () => { it('renders a dropdown with the provided actions', () => {
expect( const dropdownItems = vm.$el.querySelectorAll('.dropdown-menu li');
component.$el.querySelectorAll('.dropdown-menu li').length, expect(dropdownItems.length).toEqual(actions.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', () => { describe('scheduled jobs', () => {
expect( const scheduledJobAction = {
component.$el.querySelector('.dropdown-menu li:last-child button').getAttribute('disabled'), name: 'scheduled action',
).toEqual('disabled'); 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( it('displays 00:00:00 for expired jobs in the dropdown', () => {
component.$el.querySelector('.dropdown-menu li:last-child button').classList.contains('disabled'), expect(findDropdownItem(expiredJobAction)).toContainText('00:00:00');
).toEqual(true); });
}); });
}); });
...@@ -158,8 +158,13 @@ describe('Pipelines Table Row', () => { ...@@ -158,8 +158,13 @@ describe('Pipelines Table Row', () => {
}); });
describe('actions column', () => { describe('actions column', () => {
const scheduledJobAction = {
name: 'some scheduled job',
};
beforeEach(() => { beforeEach(() => {
const withActions = Object.assign({}, pipeline); const withActions = Object.assign({}, pipeline);
withActions.details.scheduled_actions = [scheduledJobAction];
withActions.flags.cancelable = true; withActions.flags.cancelable = true;
withActions.flags.retryable = true; withActions.flags.retryable = true;
withActions.cancel_path = '/cancel'; withActions.cancel_path = '/cancel';
...@@ -171,6 +176,8 @@ describe('Pipelines Table Row', () => { ...@@ -171,6 +176,8 @@ describe('Pipelines Table Row', () => {
it('should render the provided actions', () => { it('should render the provided actions', () => {
expect(component.$el.querySelector('.js-pipelines-retry-button')).not.toBeNull(); expect(component.$el.querySelector('.js-pipelines-retry-button')).not.toBeNull();
expect(component.$el.querySelector('.js-pipelines-cancel-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', () => { it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => {
......
...@@ -39,6 +39,14 @@ describe Gitlab::Ci::Config::Entry::Job do ...@@ -39,6 +39,14 @@ describe Gitlab::Ci::Config::Entry::Job do
expect(entry.errors).to include "job name can't be blank" expect(entry.errors).to include "job name can't be blank"
end 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 { expect(entry).to be_valid }
end
end
end end
context 'when entry value is not correct' do context 'when entry value is not correct' do
...@@ -129,6 +137,52 @@ describe Gitlab::Ci::Config::Entry::Job do ...@@ -129,6 +137,52 @@ describe Gitlab::Ci::Config::Entry::Job do
end end
end 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
end end
...@@ -238,6 +292,24 @@ describe Gitlab::Ci::Config::Entry::Job do ...@@ -238,6 +292,24 @@ describe Gitlab::Ci::Config::Entry::Job do
end end
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 describe '#ignored?' do
context 'when job is a manual action' do context 'when job is a manual action' do
context 'when it is not specified if job is allowed to fail' do context 'when it is not specified if job is allowed to fail' do
......
...@@ -319,4 +319,53 @@ describe Gitlab::Ci::Status::Build::Factory do ...@@ -319,4 +319,53 @@ describe Gitlab::Ci::Status::Build::Factory do
end end
end 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 end
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
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
...@@ -11,8 +11,7 @@ describe Gitlab::Ci::Status::Pipeline::Factory do ...@@ -11,8 +11,7 @@ describe Gitlab::Ci::Status::Pipeline::Factory do
end end
context 'when pipeline has a core status' do context 'when pipeline has a core status' do
(HasStatus::AVAILABLE_STATUSES - [HasStatus::BLOCKED_STATUS]) HasStatus::AVAILABLE_STATUSES.each do |simple_status|
.each do |simple_status|
context "when core status is #{simple_status}" do context "when core status is #{simple_status}" do
let(:pipeline) { create(:ci_pipeline, status: simple_status) } let(:pipeline) { create(:ci_pipeline, status: simple_status) }
...@@ -24,12 +23,24 @@ describe Gitlab::Ci::Status::Pipeline::Factory do ...@@ -24,12 +23,24 @@ describe Gitlab::Ci::Status::Pipeline::Factory do
expect(factory.core_status).to be_a expected_status expect(factory.core_status).to be_a expected_status
end end
it 'does not match extended statuses' do if simple_status == 'manual'
expect(factory.extended_statuses).to be_empty it 'matches a correct extended statuses' do
end expect(factory.extended_statuses)
.to eq [Gitlab::Ci::Status::Pipeline::Blocked]
it "fabricates a core status #{simple_status}" do end
expect(status).to be_a expected_status 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 end
it 'extends core status with common pipeline methods' do it 'extends core status with common pipeline methods' do
...@@ -40,27 +51,6 @@ describe Gitlab::Ci::Status::Pipeline::Factory do ...@@ -40,27 +51,6 @@ describe Gitlab::Ci::Status::Pipeline::Factory do
end end
end 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 end
context 'when pipeline has warnings' do context 'when pipeline has warnings' do
......
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
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
...@@ -121,6 +121,21 @@ module Gitlab ...@@ -121,6 +121,21 @@ module Gitlab
end end
end 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 end
describe '#stages_attributes' do describe '#stages_attributes' do
...@@ -1260,7 +1275,7 @@ module Gitlab ...@@ -1260,7 +1275,7 @@ module Gitlab
config = YAML.dump({ rspec: { script: "test", when: 1 } }) config = YAML.dump({ rspec: { script: "test", when: 1 } })
expect do expect do
Gitlab::Ci::YamlProcessor.new(config) 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 end
it "returns errors if job artifacts:name is not an a string" do it "returns errors if job artifacts:name is not an a string" do
......
...@@ -58,6 +58,7 @@ RSpec.describe Gitlab::Favicon, :request_store do ...@@ -58,6 +58,7 @@ RSpec.describe Gitlab::Favicon, :request_store do
favicon_status_not_found favicon_status_not_found
favicon_status_pending favicon_status_pending
favicon_status_running favicon_status_running
favicon_status_scheduled
favicon_status_skipped favicon_status_skipped
favicon_status_success favicon_status_success
favicon_status_warning favicon_status_warning
......
...@@ -117,6 +117,7 @@ pipelines: ...@@ -117,6 +117,7 @@ pipelines:
- retryable_builds - retryable_builds
- cancelable_statuses - cancelable_statuses
- manual_actions - manual_actions
- scheduled_actions
- artifacts - artifacts
- pipeline_schedule - pipeline_schedule
- merge_requests - merge_requests
......
...@@ -300,6 +300,7 @@ CommitStatus: ...@@ -300,6 +300,7 @@ CommitStatus:
- retried - retried
- protected - protected
- failure_reason - failure_reason
- scheduled_at
Ci::Variable: Ci::Variable:
- id - id
- project_id - project_id
......
...@@ -209,6 +209,155 @@ describe Ci::Build do ...@@ -209,6 +209,155 @@ describe Ci::Build do
end end
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 describe '#any_runners_online?' do
subject { build.any_runners_online? } subject { build.any_runners_online? }
...@@ -1193,6 +1342,12 @@ describe Ci::Build do ...@@ -1193,6 +1342,12 @@ describe Ci::Build do
it { is_expected.to be_truthy } it { is_expected.to be_truthy }
end 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 context 'when set to something else' do
let(:value) { 'something else' } let(:value) { 'something else' }
...@@ -1476,6 +1631,12 @@ describe Ci::Build do ...@@ -1476,6 +1631,12 @@ describe Ci::Build do
end end
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 context 'when build is not a manual action' do
subject { build_stubbed(:ci_build, :success) } subject { build_stubbed(:ci_build, :success) }
......
...@@ -75,6 +75,18 @@ describe Ci::Pipeline, :mailer do ...@@ -75,6 +75,18 @@ describe Ci::Pipeline, :mailer do
end end
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 describe '#valid_commit_sha' do
context 'commit.sha can not start with 00000000' do context 'commit.sha can not start with 00000000' do
before do before do
...@@ -1339,6 +1351,19 @@ describe Ci::Pipeline, :mailer do ...@@ -1339,6 +1351,19 @@ describe Ci::Pipeline, :mailer do
end end
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 context 'when statuses status was not recognized' do
before do before do
allow(pipeline) allow(pipeline)
......
...@@ -89,6 +89,18 @@ describe Ci::Stage, :models do ...@@ -89,6 +89,18 @@ describe Ci::Stage, :models do
end end
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 context 'when stage is skipped because is empty' do
it 'updates status to skipped' do it 'updates status to skipped' do
expect { stage.update_status } expect { stage.update_status }
...@@ -188,6 +200,18 @@ describe Ci::Stage, :models do ...@@ -188,6 +200,18 @@ describe Ci::Stage, :models do
end end
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 describe '#position' do
context 'when stage has been imported and does not have position index set' do context 'when stage has been imported and does not have position index set' do
before do before do
......
...@@ -129,6 +129,20 @@ describe CommitStatus do ...@@ -129,6 +129,20 @@ describe CommitStatus do
end end
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 describe '#auto_canceled?' do
subject { commit_status.auto_canceled? } subject { commit_status.auto_canceled? }
...@@ -564,6 +578,12 @@ describe CommitStatus do ...@@ -564,6 +578,12 @@ describe CommitStatus do
it_behaves_like 'commit status enqueued' it_behaves_like 'commit status enqueued'
end end
context 'when initial state is :scheduled' do
let(:commit_status) { create(:commit_status, :scheduled) }
it_behaves_like 'commit status enqueued'
end
end end
describe '#present' do describe '#present' do
......
...@@ -270,11 +270,11 @@ describe HasStatus do ...@@ -270,11 +270,11 @@ describe HasStatus do
describe '.cancelable' do describe '.cancelable' do
subject { CommitStatus.cancelable } 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 it_behaves_like 'containing the job', status
end 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 it_behaves_like 'not containing the job', status
end end
end end
...@@ -290,6 +290,18 @@ describe HasStatus do ...@@ -290,6 +290,18 @@ describe HasStatus do
it_behaves_like 'not containing the job', status it_behaves_like 'not containing the job', status
end end
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 end
describe '::DEFAULT_STATUS' do describe '::DEFAULT_STATUS' do
...@@ -300,7 +312,41 @@ describe HasStatus do ...@@ -300,7 +312,41 @@ describe HasStatus do
describe '::BLOCKED_STATUS' do describe '::BLOCKED_STATUS' do
it 'is a status manual' 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 end
end end
...@@ -218,6 +218,42 @@ describe Ci::BuildPresenter do ...@@ -218,6 +218,42 @@ describe Ci::BuildPresenter do
end end
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 describe '#callout_failure_message' do
let(:build) { create(:ci_build, :failed, :api_failure) } let(:build) { create(:ci_build, :failed, :api_failure) }
......
...@@ -22,5 +22,17 @@ describe BuildActionEntity do ...@@ -22,5 +22,17 @@ describe BuildActionEntity do
it 'contains whether it is playable' do it 'contains whether it is playable' do
expect(subject[:playable]).to eq job.playable? expect(subject[:playable]).to eq job.playable?
end 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
end end
...@@ -109,6 +109,18 @@ describe JobEntity do ...@@ -109,6 +109,18 @@ describe JobEntity do
end end
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 context 'when job is generic commit status' do
let(:job) { create(:generic_commit_status, target_url: 'http://google.com') } let(:job) { create(:generic_commit_status, target_url: 'http://google.com') }
......
...@@ -29,7 +29,7 @@ describe PipelineDetailsEntity do ...@@ -29,7 +29,7 @@ describe PipelineDetailsEntity do
expect(subject[:details]) expect(subject[:details])
.to include :duration, :finished_at .to include :duration, :finished_at
expect(subject[:details]) 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 expect(subject[:details][:status]).to include :icon, :favicon, :text, :label
end end
......
# 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
# 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
...@@ -31,17 +31,14 @@ describe Ci::ProcessPipelineService, '#execute' do ...@@ -31,17 +31,14 @@ describe Ci::ProcessPipelineService, '#execute' do
succeed_pending succeed_pending
expect(builds.success.count).to eq(2) expect(builds.success.count).to eq(2)
expect(process_pipeline).to be_truthy
succeed_pending succeed_pending
expect(builds.success.count).to eq(4) expect(builds.success.count).to eq(4)
expect(process_pipeline).to be_truthy
succeed_pending succeed_pending
expect(builds.success.count).to eq(5) expect(builds.success.count).to eq(5)
expect(process_pipeline).to be_falsey
end end
it 'does not process pipeline if existing stage is running' do it 'does not process pipeline if existing stage is running' do
...@@ -242,6 +239,187 @@ describe Ci::ProcessPipelineService, '#execute' do ...@@ -242,6 +239,187 @@ describe Ci::ProcessPipelineService, '#execute' do
end end
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 there are manual action in earlier stages' do
context 'when first stage has only optional manual actions' do context 'when first stage has only optional manual actions' do
before do before do
...@@ -536,6 +714,13 @@ describe Ci::ProcessPipelineService, '#execute' do ...@@ -536,6 +714,13 @@ describe Ci::ProcessPipelineService, '#execute' do
builds.pluck(:name) builds.pluck(:name)
end 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 def all_builds_names
all_builds.pluck(:name) all_builds.pluck(:name)
end end
...@@ -549,7 +734,7 @@ describe Ci::ProcessPipelineService, '#execute' do ...@@ -549,7 +734,7 @@ describe Ci::ProcessPipelineService, '#execute' do
end end
def succeed_pending def succeed_pending
builds.pending.update_all(status: 'success') builds.pending.map(&:success)
end end
def succeed_running_or_pending def succeed_running_or_pending
...@@ -568,6 +753,14 @@ describe Ci::ProcessPipelineService, '#execute' do ...@@ -568,6 +753,14 @@ describe Ci::ProcessPipelineService, '#execute' do
builds.find_by(name: name).play(user) builds.find_by(name: name).play(user)
end 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 def manual_actions
pipeline.manual_actions(true) pipeline.manual_actions(true)
end end
...@@ -575,4 +768,12 @@ describe Ci::ProcessPipelineService, '#execute' do ...@@ -575,4 +768,12 @@ describe Ci::ProcessPipelineService, '#execute' do
def create_build(name, **opts) def create_build(name, **opts)
create(:ci_build, :created, pipeline: pipeline, name: name, **opts) create(:ci_build, :created, pipeline: pipeline, name: name, **opts)
end end
def delayed_options
{ when: 'delayed', options: { start_in: '1 minute' } }
end
def unschedule
pipeline.builds.scheduled.map(&:unschedule)
end
end end
...@@ -27,7 +27,7 @@ describe Ci::RetryBuildService do ...@@ -27,7 +27,7 @@ describe Ci::RetryBuildService do
job_artifacts_metadata job_artifacts_trace job_artifacts_junit job_artifacts_metadata job_artifacts_trace job_artifacts_junit
job_artifacts_sast job_artifacts_dependency_scanning job_artifacts_sast job_artifacts_dependency_scanning
job_artifacts_container_scanning job_artifacts_dast job_artifacts_container_scanning job_artifacts_dast
job_artifacts_codequality].freeze job_artifacts_codequality scheduled_at].freeze
IGNORE_ACCESSORS = IGNORE_ACCESSORS =
%i[type lock_version target_url base_tags trace_sections %i[type lock_version target_url base_tags trace_sections
...@@ -44,7 +44,8 @@ describe Ci::RetryBuildService do ...@@ -44,7 +44,8 @@ describe Ci::RetryBuildService do
create(:ci_build, :failed, :expired, :erased, :queued, :coverage, :tags, create(:ci_build, :failed, :expired, :erased, :queued, :coverage, :tags,
:allowed_to_fail, :on_tag, :triggered, :teardown_environment, :allowed_to_fail, :on_tag, :triggered, :teardown_environment,
description: 'my-job', stage: 'test', stage_id: stage.id, 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 end
before do before do
......
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
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
...@@ -127,6 +127,47 @@ describe StuckCiJobsWorker do ...@@ -127,6 +127,47 @@ describe StuckCiJobsWorker do
end end
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 describe 'exclusive lease' do
let(:status) { 'running' } let(:status) { 'running' }
let(:updated_at) { 2.days.ago } let(:updated_at) { 2.days.ago }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment