Commit 8c1859a1 authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'specify-job-level-timeout' into 'master'

Allow timeout property to be provided per build

See merge request gitlab-org/gitlab!16777
parents 6bc45429 e1b71187
...@@ -4,9 +4,12 @@ module Ci ...@@ -4,9 +4,12 @@ module Ci
# The purpose of this class is to store Build related data that can be disposed. # The purpose of this class is to store Build related data that can be disposed.
# Data that should be persisted forever, should be stored with Ci::Build model. # Data that should be persisted forever, should be stored with Ci::Build model.
class BuildMetadata < ApplicationRecord class BuildMetadata < ApplicationRecord
BuildTimeout = Struct.new(:value, :source)
extend Gitlab::Ci::Model extend Gitlab::Ci::Model
include Presentable include Presentable
include ChronicDurationAttribute include ChronicDurationAttribute
include Gitlab::Utils::StrongMemoize
self.table_name = 'ci_builds_metadata' self.table_name = 'ci_builds_metadata'
...@@ -25,17 +28,16 @@ module Ci ...@@ -25,17 +28,16 @@ module Ci
enum timeout_source: { enum timeout_source: {
unknown_timeout_source: 1, unknown_timeout_source: 1,
project_timeout_source: 2, project_timeout_source: 2,
runner_timeout_source: 3 runner_timeout_source: 3,
job_timeout_source: 4
} }
def update_timeout_state def update_timeout_state
return unless build.runner.present? timeout = timeout_with_highest_precedence
project_timeout = project&.build_timeout return unless timeout
timeout = [project_timeout, build.runner.maximum_timeout].compact.min
timeout_source = timeout < project_timeout ? :runner_timeout_source : :project_timeout_source
update(timeout: timeout, timeout_source: timeout_source) update(timeout: timeout.value, timeout_source: timeout.source)
end end
private private
...@@ -43,5 +45,37 @@ module Ci ...@@ -43,5 +45,37 @@ module Ci
def set_build_project def set_build_project
self.project_id ||= self.build.project_id self.project_id ||= self.build.project_id
end end
def timeout_with_highest_precedence
[(job_timeout || project_timeout), runner_timeout].compact.min_by { |timeout| timeout.value }
end
def project_timeout
strong_memoize(:project_timeout) do
BuildTimeout.new(project&.build_timeout, :project_timeout_source)
end
end
def job_timeout
return unless build.options
strong_memoize(:job_timeout) do
if timeout_from_options = build.options[:job_timeout]
BuildTimeout.new(timeout_from_options, :job_timeout_source)
end
end
end
def runner_timeout
return unless runner_timeout_set?
strong_memoize(:runner_timeout) do
BuildTimeout.new(build.runner.maximum_timeout, :runner_timeout_source)
end
end
def runner_timeout_set?
build.runner&.maximum_timeout.to_i > 0
end
end end
end end
...@@ -58,6 +58,7 @@ class Project < ApplicationRecord ...@@ -58,6 +58,7 @@ class Project < ApplicationRecord
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
SORTING_PREFERENCE_FIELD = :projects_sort SORTING_PREFERENCE_FIELD = :projects_sort
MAX_BUILD_TIMEOUT = 1.month
cache_markdown_field :description, pipeline: :description cache_markdown_field :description, pipeline: :description
...@@ -430,7 +431,7 @@ class Project < ApplicationRecord ...@@ -430,7 +431,7 @@ class Project < ApplicationRecord
validates :build_timeout, allow_nil: true, validates :build_timeout, allow_nil: true,
numericality: { greater_than_or_equal_to: 10.minutes, numericality: { greater_than_or_equal_to: 10.minutes,
less_than: 1.month, less_than: MAX_BUILD_TIMEOUT,
only_integer: true, only_integer: true,
message: _('needs to be between 10 minutes and 1 month') } message: _('needs to be between 10 minutes and 1 month') }
......
...@@ -5,7 +5,8 @@ module Ci ...@@ -5,7 +5,8 @@ module Ci
TIMEOUT_SOURCES = { TIMEOUT_SOURCES = {
unknown_timeout_source: nil, unknown_timeout_source: nil,
project_timeout_source: 'project', project_timeout_source: 'project',
runner_timeout_source: 'runner' runner_timeout_source: 'runner',
job_timeout_source: 'job'
}.freeze }.freeze
presents :metadata presents :metadata
......
---
title: Allow specifying timeout per-job in .gitlab-ci.yml
merge_request: 16777
author: Michał Siwek
type: added
...@@ -110,6 +110,7 @@ The following table lists available parameters for jobs: ...@@ -110,6 +110,7 @@ The following table lists available parameters for jobs:
| [`dependencies`](#dependencies) | Other jobs that a job depends on so that you can pass artifacts between them. | | [`dependencies`](#dependencies) | Other jobs that a job depends on so that you can pass artifacts between them. |
| [`coverage`](#coverage) | Code coverage settings for a given job. | | [`coverage`](#coverage) | Code coverage settings for a given job. |
| [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. | | [`retry`](#retry) | When and how many times a job can be auto-retried in case of a failure. |
| [`timeout`](#timeout) | Define a custom timeout that would take precedence over the project-wide one. |
| [`parallel`](#parallel) | How many instances of a job should be run in parallel. | | [`parallel`](#parallel) | How many instances of a job should be run in parallel. |
| [`trigger`](#trigger-premium) | Defines a downstream pipeline trigger. | | [`trigger`](#trigger-premium) | Defines a downstream pipeline trigger. |
| [`include`](#include) | Allows this job to include external YAML files. Also available: `include:local`, `include:file`, `include:template`, and `include:remote`. | | [`include`](#include) | Allows this job to include external YAML files. Also available: `include:local`, `include:file`, `include:template`, and `include:remote`. |
...@@ -1995,6 +1996,20 @@ Possible values for `when` are: ...@@ -1995,6 +1996,20 @@ Possible values for `when` are:
- `missing_dependency_failure`: Retry if a dependency was missing. - `missing_dependency_failure`: Retry if a dependency was missing.
- `runner_unsupported`: Retry if the runner was unsupported. - `runner_unsupported`: Retry if the runner was unsupported.
### timeout
`timeout` allows you to configure a timeout for a specific job:
```yaml
build:
script: build.sh
timeout: 3 hours 30 minutes
test:
script: rspec
timeout: 3h 30m
```
### `parallel` ### `parallel`
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22631) in GitLab 11.5. > [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22631) in GitLab 11.5.
......
...@@ -14,8 +14,8 @@ module Gitlab ...@@ -14,8 +14,8 @@ module Gitlab
ALLOWED_WHEN = %w[on_success on_failure always manual delayed].freeze ALLOWED_WHEN = %w[on_success on_failure always manual delayed].freeze
ALLOWED_KEYS = %i[tags script only except rules type image services ALLOWED_KEYS = %i[tags script only except rules type image services
allow_failure type stage when start_in artifacts cache allow_failure type stage when start_in artifacts cache
dependencies needs before_script after_script variables dependencies before_script needs after_script variables
environment coverage retry parallel extends interruptible].freeze environment coverage retry parallel extends interruptible timeout].freeze
REQUIRED_BY_NEEDS = %i[stage].freeze REQUIRED_BY_NEEDS = %i[stage].freeze
...@@ -46,6 +46,8 @@ module Gitlab ...@@ -46,6 +46,8 @@ module Gitlab
message: "should be one of: #{ALLOWED_WHEN.join(', ')}" message: "should be one of: #{ALLOWED_WHEN.join(', ')}"
} }
validates :timeout, duration: { limit: ChronicDuration.output(Project::MAX_BUILD_TIMEOUT) }
validates :dependencies, array_of_strings: true validates :dependencies, array_of_strings: true
validates :needs, array_of_strings: true validates :needs, array_of_strings: true
validates :extends, array_of_strings_or_string: true validates :extends, array_of_strings_or_string: true
...@@ -127,7 +129,7 @@ module Gitlab ...@@ -127,7 +129,7 @@ module Gitlab
attributes :script, :tags, :allow_failure, :when, :dependencies, attributes :script, :tags, :allow_failure, :when, :dependencies,
:needs, :retry, :parallel, :extends, :start_in, :rules, :needs, :retry, :parallel, :extends, :start_in, :rules,
:interruptible :interruptible, :timeout
def self.matching?(name, config) def self.matching?(name, config)
!name.to_s.start_with?('.') && !name.to_s.start_with?('.') &&
...@@ -218,6 +220,7 @@ module Gitlab ...@@ -218,6 +220,7 @@ module Gitlab
retry: retry_defined? ? retry_value : nil, retry: retry_defined? ? retry_value : nil,
parallel: parallel_defined? ? parallel_value.to_i : nil, parallel: parallel_defined? ? parallel_value.to_i : nil,
interruptible: interruptible_defined? ? interruptible_value : nil, interruptible: interruptible_defined? ? interruptible_value : nil,
timeout: has_timeout? ? ChronicDuration.parse(timeout.to_s) : nil,
artifacts: artifacts_value, artifacts: artifacts_value,
after_script: after_script_value, after_script: after_script_value,
ignore: ignored?, ignore: ignored?,
......
...@@ -49,6 +49,7 @@ module Gitlab ...@@ -49,6 +49,7 @@ module Gitlab
artifacts: job[:artifacts], artifacts: job[:artifacts],
cache: job[:cache], cache: job[:cache],
dependencies: job[:dependencies], dependencies: job[:dependencies],
job_timeout: job[:timeout],
before_script: job[:before_script], before_script: job[:before_script],
script: job[:script], script: job[:script],
after_script: job[:after_script], after_script: job[:after_script],
......
...@@ -4,39 +4,49 @@ require 'spec_helper' ...@@ -4,39 +4,49 @@ require 'spec_helper'
describe Gitlab::Ci::Build::Step do describe Gitlab::Ci::Build::Step do
describe '#from_commands' do describe '#from_commands' do
shared_examples 'has correct script' do subject { described_class.from_commands(job) }
subject { described_class.from_commands(job) }
before do before do
job.run! job.run!
end end
shared_examples 'has correct script' do
it 'fabricates an object' do it 'fabricates an object' do
expect(subject.name).to eq(:script) expect(subject.name).to eq(:script)
expect(subject.script).to eq(script) expect(subject.script).to eq(script)
expect(subject.timeout).to eq(job.metadata_timeout)
expect(subject.when).to eq('on_success') expect(subject.when).to eq('on_success')
expect(subject.allow_failure).to be_falsey expect(subject.allow_failure).to be_falsey
end end
end end
context 'when script option is specified' do context 'when script option is specified' do
it_behaves_like 'has correct script' do let(:job) { create(:ci_build, :no_options, options: { script: ["ls -la\necho aaa", "date"] }) }
let(:job) { create(:ci_build, :no_options, options: { script: ["ls -la\necho aaa", "date"] }) } let(:script) { ["ls -la\necho aaa", 'date'] }
let(:script) { ["ls -la\necho aaa", 'date'] }
end it_behaves_like 'has correct script'
end end
context 'when before and script option is specified' do context 'when before and script option is specified' do
it_behaves_like 'has correct script' do let(:job) do
let(:job) do create(:ci_build, options: {
create(:ci_build, options: { before_script: ["ls -la\necho aaa"],
before_script: ["ls -la\necho aaa"], script: ["date"]
script: ["date"] })
}) end
end
let(:script) { ["ls -la\necho aaa", 'date'] }
let(:script) { ["ls -la\necho aaa", 'date'] }
it_behaves_like 'has correct script'
end
context 'when timeout option is specified in seconds' do
let(:job) { create(:ci_build, options: { job_timeout: 3, script: ["ls -la\necho aaa", 'date'] }) }
let(:script) { ["ls -la\necho aaa", 'date'] }
it_behaves_like 'has correct script'
it 'has job level timeout' do
expect(subject.timeout).to eq(3)
end end
end end
end end
...@@ -57,12 +67,12 @@ describe Gitlab::Ci::Build::Step do ...@@ -57,12 +67,12 @@ describe Gitlab::Ci::Build::Step do
end end
context 'when after_script is not empty' do context 'when after_script is not empty' do
let(:job) { create(:ci_build, options: { script: ['bash'], after_script: ['ls -la', 'date'] }) } let(:job) { create(:ci_build, options: { job_timeout: 60, script: ['bash'], after_script: ['ls -la', 'date'] }) }
it 'fabricates an object' do it 'fabricates an object' do
expect(subject.name).to eq(:after_script) expect(subject.name).to eq(:after_script)
expect(subject.script).to eq(['ls -la', 'date']) expect(subject.script).to eq(['ls -la', 'date'])
expect(subject.timeout).to eq(job.metadata_timeout) expect(subject.timeout).to eq(60)
expect(subject.when).to eq('always') expect(subject.when).to eq('always')
expect(subject.allow_failure).to be_truthy expect(subject.allow_failure).to be_truthy
end end
......
...@@ -417,6 +417,37 @@ describe Gitlab::Ci::Config::Entry::Job do ...@@ -417,6 +417,37 @@ describe Gitlab::Ci::Config::Entry::Job do
end end
end end
end end
context 'when timeout value is not correct' do
context 'when it is higher than instance wide timeout' do
let(:config) { { timeout: '3 months' } }
it 'returns error about value too high' do
expect(entry).not_to be_valid
expect(entry.errors)
.to include "job timeout should not exceed the limit"
end
end
context 'when it is not a duration' do
let(:config) { { timeout: 100 } }
it 'returns error about wrong value' do
expect(entry).not_to be_valid
expect(entry.errors).to include 'job timeout should be a duration'
end
end
end
context 'when timeout value is correct' do
let(:config) { { script: 'echo', timeout: '1m 1s' } }
it 'returns correct timeout' do
expect(entry).to be_valid
expect(entry.errors).to be_empty
expect(entry.timeout).to eq('1m 1s')
end
end
end end
end end
......
...@@ -1134,6 +1134,48 @@ module Gitlab ...@@ -1134,6 +1134,48 @@ module Gitlab
end end
end end
describe "Timeout" do
let(:config) do
{
deploy_to_production: {
stage: 'deploy',
script: 'test'
}
}
end
let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) }
let(:builds) { processor.stage_builds_attributes('deploy') }
context 'when no timeout was provided' do
it 'does not include job_timeout' do
expect(builds.size).to eq(1)
expect(builds.first[:options]).not_to include(:job_timeout)
end
end
context 'when an invalid timeout was provided' do
before do
config[:deploy_to_production][:timeout] = 'not-a-number'
end
it 'raises an error for invalid number' do
expect { builds }.to raise_error('jobs:deploy_to_production timeout should be a duration')
end
end
context 'when some valid timeout was provided' do
before do
config[:deploy_to_production][:timeout] = '1m 3s'
end
it 'returns provided timeout value' do
expect(builds.size).to eq(1)
expect(builds.first[:options]).to include(job_timeout: 63)
end
end
end
describe "Dependencies" do describe "Dependencies" do
let(:config) do let(:config) do
{ {
......
...@@ -22,42 +22,72 @@ describe Ci::BuildMetadata do ...@@ -22,42 +22,72 @@ describe Ci::BuildMetadata do
describe '#update_timeout_state' do describe '#update_timeout_state' do
subject { metadata } subject { metadata }
context 'when runner is not assigned to the job' do shared_examples 'sets timeout' do |source, timeout|
it "doesn't change timeout value" do it 'sets project_timeout_source' do
expect { subject.update_timeout_state }.not_to change { subject.reload.timeout } expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to(source)
end end
it "doesn't change timeout_source value" do it 'sets project timeout' do
expect { subject.update_timeout_state }.not_to change { subject.reload.timeout_source } expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(timeout)
end end
end end
context 'when runner is assigned to the job' do context 'when project timeout is set' do
before do context 'when runner is assigned to the job' do
build.update(runner: runner) before do
build.update!(runner: runner)
end
context 'when runner timeout is not set' do
let(:runner) { create(:ci_runner, maximum_timeout: nil) }
it_behaves_like 'sets timeout', 'project_timeout_source', 2000
end
context 'when runner timeout is lower than project timeout' do
let(:runner) { create(:ci_runner, maximum_timeout: 1900) }
it_behaves_like 'sets timeout', 'runner_timeout_source', 1900
end
context 'when runner timeout is higher than project timeout' do
let(:runner) { create(:ci_runner, maximum_timeout: 2100) }
it_behaves_like 'sets timeout', 'project_timeout_source', 2000
end
end end
context 'when runner timeout is lower than project timeout' do context 'when job timeout is set' do
let(:runner) { create(:ci_runner, maximum_timeout: 1900) } context 'when job timeout is higher than project timeout' do
let(:build) { create(:ci_build, pipeline: pipeline, options: { job_timeout: 3000 }) }
it 'sets runner timeout' do it_behaves_like 'sets timeout', 'job_timeout_source', 3000
expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(1900)
end end
it 'sets runner_timeout_source' do context 'when job timeout is lower than project timeout' do
expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to('runner_timeout_source') let(:build) { create(:ci_build, pipeline: pipeline, options: { job_timeout: 1000 }) }
it_behaves_like 'sets timeout', 'job_timeout_source', 1000
end end
end end
context 'when runner timeout is higher than project timeout' do context 'when both runner and job timeouts are set' do
let(:runner) { create(:ci_runner, maximum_timeout: 2100) } before do
build.update(runner: runner)
end
context 'when job timeout is higher than runner timeout' do
let(:build) { create(:ci_build, pipeline: pipeline, options: { job_timeout: 3000 }) }
let(:runner) { create(:ci_runner, maximum_timeout: 2100) }
it 'sets project timeout' do it_behaves_like 'sets timeout', 'runner_timeout_source', 2100
expect { subject.update_timeout_state }.to change { subject.reload.timeout }.to(2000)
end end
it 'sets project_timeout_source' do context 'when job timeout is lower than runner timeout' do
expect { subject.update_timeout_state }.to change { subject.reload.timeout_source }.to('project_timeout_source') let(:build) { create(:ci_build, pipeline: pipeline, options: { job_timeout: 1900 }) }
let(:runner) { create(:ci_runner, maximum_timeout: 2100) }
it_behaves_like 'sets timeout', 'job_timeout_source', 1900
end end
end end
end end
......
...@@ -795,6 +795,22 @@ describe Ci::CreatePipelineService do ...@@ -795,6 +795,22 @@ describe Ci::CreatePipelineService do
end end
end end
context 'with timeout' do
context 'when builds with custom timeouts are configured' do
before do
config = YAML.dump(rspec: { script: 'rspec', timeout: '2m 3s' })
stub_ci_pipeline_yaml_file(config)
end
it 'correctly creates builds with custom timeout value configured' do
pipeline = execute_service
expect(pipeline).to be_persisted
expect(pipeline.builds.find_by(name: 'rspec').options[:job_timeout]).to eq 123
end
end
end
shared_examples 'when ref is protected' do shared_examples 'when ref is protected' do
let(:user) { create(:user) } let(:user) { create(:user) }
......
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