summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKamil Trzciński <ayufan@ayufan.eu>2018-09-05 18:13:02 +0000
committerKamil Trzciński <ayufan@ayufan.eu>2018-09-05 18:13:02 +0000
commit498f3d83ca2abde4776d581f43d270ee45e4168a (patch)
treeff8457d83e5ea9d1dea651a039795eb999c86ee1
parentd9f05f87ee01d99109505a2b90527d4712f57ee3 (diff)
parente8648241e4af7d5f9ae1345fef9636bfdbb85557 (diff)
downloadgitlab-ce-498f3d83ca2abde4776d581f43d270ee45e4168a.tar.gz
Merge branch 'feature/gb/allow-to-extend-keys-in-gitlab-ci-yml' into 'master'
Add support for advanced CI/CD config extension with `extends:` Closes gitlab-ee#6136 See merge request gitlab-org/gitlab-ce!21243
-rw-r--r--changelogs/unreleased/feature-gb-allow-to-extend-keys-in-gitlab-ci-yml.yml5
-rw-r--r--doc/ci/yaml/README.md74
-rw-r--r--lib/gitlab/ci/config.rb14
-rw-r--r--lib/gitlab/ci/config/entry/job.rb11
-rw-r--r--lib/gitlab/ci/config/extendable.rb29
-rw-r--r--lib/gitlab/ci/config/extendable/entry.rb95
-rw-r--r--lib/gitlab/ci/yaml_processor.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb14
-rw-r--r--spec/lib/gitlab/ci/config/extendable/entry_spec.rb227
-rw-r--r--spec/lib/gitlab/ci/config/extendable_spec.rb228
-rw-r--r--spec/lib/gitlab/ci/config_spec.rb52
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb60
12 files changed, 801 insertions, 10 deletions
diff --git a/changelogs/unreleased/feature-gb-allow-to-extend-keys-in-gitlab-ci-yml.yml b/changelogs/unreleased/feature-gb-allow-to-extend-keys-in-gitlab-ci-yml.yml
new file mode 100644
index 00000000000..b46dfd47e7a
--- /dev/null
+++ b/changelogs/unreleased/feature-gb-allow-to-extend-keys-in-gitlab-ci-yml.yml
@@ -0,0 +1,5 @@
+---
+title: Add support for extendable CI/CD config with
+merge_request: 21243
+author:
+type: added
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index e93060fec85..c1ebe39e076 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -56,6 +56,7 @@ A job is defined by a list of parameters that define the job behavior.
| Keyword | Required | Description |
|---------------|----------|-------------|
| script | yes | Defines a shell script which is executed by Runner |
+| extends | no | Defines a configuration entry that this job is going to inherit from |
| image | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
| services | no | Use docker services, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-gitlab-ciyml) |
| stage | no | Defines a job stage (default: `test`) |
@@ -75,6 +76,79 @@ A job is defined by a list of parameters that define the job behavior.
| coverage | no | Define code coverage settings for a given job |
| retry | no | Define how many times a job can be auto-retried in case of a failure |
+### `extends`
+
+> Introduced in GitLab 11.3
+
+`extends` defines an entry name that a job, that uses `extends` is going to
+inherit from.
+
+`extends` in an alternative to using [YAML anchors](#anchors) that is a little
+more flexible and readable.
+
+```yaml
+.tests:
+ only:
+ refs:
+ - branches
+
+rspec:
+ extends: .tests
+ script: rake rspec
+ stage: test
+ only:
+ variables:
+ - $RSPEC
+```
+
+In the example above the `rspec` job is going to inherit from `.tests`
+template. GitLab will perform a reverse deep merge, what means that it will
+merge `rspec` contents into `.tests` recursively, and it is going to result in
+following configuration of the `rspec` job:
+
+```yaml
+rspec:
+ script: rake rspec
+ stage: test
+ only:
+ refs:
+ - branches
+ variables:
+ - $RSPEC
+```
+
+`.tests` in this example is a [hidden key](#hidden-keys-jobs), but it is
+possible to inherit from regular jobs as well.
+
+`extends` supports multi-level inheritance, however it is not recommended to
+use more than three levels of inheritance. Maximum nesting level supported is
+10 levels.
+
+
+```yaml
+.tests:
+ only:
+ - pushes
+
+.rspec:
+ extends: .tests
+ script: rake rspec
+
+rspec 1:
+ variables:
+ RSPEC_SUITE: '1'
+ extends: .rspec
+
+rspec 2:
+ variables:
+ RSPEC_SUITE: '2'
+ extends: .rspec
+
+spinach:
+ extends: .tests
+ script: rake spinach
+```
+
### `pages`
`pages` is a special job that is used to upload static content to GitLab that
diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb
index 66ac4a40616..46dad59eb8c 100644
--- a/lib/gitlab/ci/config.rb
+++ b/lib/gitlab/ci/config.rb
@@ -4,12 +4,17 @@ module Gitlab
# Base GitLab CI Configuration facade
#
class Config
- # EE would override this and utilize opts argument
+ ConfigError = Class.new(StandardError)
+
def initialize(config, opts = {})
- @config = Loader.new(config).load!
+ @config = Config::Extendable
+ .new(build_config(config, opts))
+ .to_hash
@global = Entry::Global.new(@config)
@global.compose!
+ rescue Loader::FormatError, Extendable::ExtensionError => e
+ raise Config::ConfigError, e.message
end
def valid?
@@ -58,6 +63,11 @@ module Gitlab
def jobs
@global.jobs_value
end
+
+ # 'opts' argument is used in EE see /ee/lib/ee/gitlab/ci/config.rb
+ def build_config(config, opts = {})
+ Loader.new(config).load!
+ end
end
end
end
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index 91aac6df4b1..016a896bde5 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -9,9 +9,10 @@ module Gitlab
include Configurable
include Attributable
- ALLOWED_KEYS = %i[tags script only except type image services allow_failure
- type stage when artifacts cache dependencies before_script
- after_script variables environment coverage retry].freeze
+ ALLOWED_KEYS = %i[tags script only except type image services
+ allow_failure type stage when artifacts cache
+ dependencies before_script after_script variables
+ environment coverage retry extends].freeze
validations do
validates :config, allowed_keys: ALLOWED_KEYS
@@ -32,6 +33,7 @@ module Gitlab
'always or manual' }
validates :dependencies, array_of_strings: true
+ validates :extends, type: String
end
end
@@ -81,7 +83,8 @@ module Gitlab
:cache, :image, :services, :only, :except, :variables,
:artifacts, :commands, :environment, :coverage, :retry
- attributes :script, :tags, :allow_failure, :when, :dependencies, :retry
+ attributes :script, :tags, :allow_failure, :when, :dependencies,
+ :retry, :extends
def compose!(deps = nil)
super do
diff --git a/lib/gitlab/ci/config/extendable.rb b/lib/gitlab/ci/config/extendable.rb
new file mode 100644
index 00000000000..a43901c69fe
--- /dev/null
+++ b/lib/gitlab/ci/config/extendable.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Config
+ class Extendable
+ include Enumerable
+
+ ExtensionError = Class.new(StandardError)
+
+ def initialize(hash)
+ @hash = hash.to_h.deep_dup
+
+ each { |entry| entry.extend! if entry.extensible? }
+ end
+
+ def each
+ @hash.each_key do |key|
+ yield Extendable::Entry.new(key, @hash)
+ end
+ end
+
+ def to_hash
+ @hash.to_h
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/config/extendable/entry.rb b/lib/gitlab/ci/config/extendable/entry.rb
new file mode 100644
index 00000000000..7793db09d33
--- /dev/null
+++ b/lib/gitlab/ci/config/extendable/entry.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ class Config
+ class Extendable
+ class Entry
+ InvalidExtensionError = Class.new(Extendable::ExtensionError)
+ CircularDependencyError = Class.new(Extendable::ExtensionError)
+ NestingTooDeepError = Class.new(Extendable::ExtensionError)
+
+ MAX_NESTING_LEVELS = 10
+
+ attr_reader :key
+
+ def initialize(key, context, parent = nil)
+ @key = key
+ @context = context
+ @parent = parent
+
+ unless @context.key?(@key)
+ raise StandardError, 'Invalid entry key!'
+ end
+ end
+
+ def extensible?
+ value.is_a?(Hash) && value.key?(:extends)
+ end
+
+ def value
+ @value ||= @context.fetch(@key)
+ end
+
+ def base_hash!
+ @base ||= Extendable::Entry
+ .new(extends_key, @context, self)
+ .extend!
+ end
+
+ def extends_key
+ value.fetch(:extends).to_s.to_sym if extensible?
+ end
+
+ def ancestors
+ @ancestors ||= Array(@parent&.ancestors) + Array(@parent&.key)
+ end
+
+ def extend!
+ return value unless extensible?
+
+ if unknown_extension?
+ raise Entry::InvalidExtensionError,
+ "#{key}: unknown key in `extends`"
+ end
+
+ if invalid_base?
+ raise Entry::InvalidExtensionError,
+ "#{key}: invalid base hash in `extends`"
+ end
+
+ if nesting_too_deep?
+ raise Entry::NestingTooDeepError,
+ "#{key}: nesting too deep in `extends`"
+ end
+
+ if circular_dependency?
+ raise Entry::CircularDependencyError,
+ "#{key}: circular dependency detected in `extends`"
+ end
+
+ @context[key] = base_hash!.deep_merge(value)
+ end
+
+ private
+
+ def nesting_too_deep?
+ ancestors.count > MAX_NESTING_LEVELS
+ end
+
+ def circular_dependency?
+ ancestors.include?(key)
+ end
+
+ def unknown_extension?
+ !@context.key?(extends_key)
+ end
+
+ def invalid_base?
+ !@context[extends_key].is_a?(Hash)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb
index e829f2a95f8..5d1864ae9e2 100644
--- a/lib/gitlab/ci/yaml_processor.rb
+++ b/lib/gitlab/ci/yaml_processor.rb
@@ -16,7 +16,7 @@ module Gitlab
end
initial_parsing
- rescue Gitlab::Ci::Config::Loader::FormatError => e
+ rescue Gitlab::Ci::Config::ConfigError => e
raise ValidationError, e.message
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index 6769f64f950..2c9758401b7 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -1,4 +1,5 @@
-require 'spec_helper'
+require 'fast_spec_helper'
+require_dependency 'active_model'
describe Gitlab::Ci::Config::Entry::Job do
let(:entry) { described_class.new(config, name: :rspec) }
@@ -81,6 +82,15 @@ describe Gitlab::Ci::Config::Entry::Job do
end
end
+ context 'when extends key is not a string' do
+ let(:config) { { extends: 123 } }
+
+ it 'returns error about wrong value type' do
+ expect(entry).not_to be_valid
+ expect(entry.errors).to include "job extends should be a string"
+ end
+ end
+
context 'when retry value is not correct' do
context 'when it is not a numeric value' do
let(:config) { { retry: true } }
@@ -124,6 +134,8 @@ describe Gitlab::Ci::Config::Entry::Job do
describe '#relevant?' do
it 'is a relevant entry' do
+ entry = described_class.new({ script: 'rspec' }, name: :rspec)
+
expect(entry).to be_relevant
end
end
diff --git a/spec/lib/gitlab/ci/config/extendable/entry_spec.rb b/spec/lib/gitlab/ci/config/extendable/entry_spec.rb
new file mode 100644
index 00000000000..0a148375d11
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/extendable/entry_spec.rb
@@ -0,0 +1,227 @@
+require 'fast_spec_helper'
+
+describe Gitlab::Ci::Config::Extendable::Entry do
+ describe '.new' do
+ context 'when entry key is not included in the context hash' do
+ it 'raises error' do
+ expect { described_class.new(:test, something: 'something') }
+ .to raise_error StandardError, 'Invalid entry key!'
+ end
+ end
+ end
+
+ describe '#value' do
+ it 'reads a hash value from the context' do
+ entry = described_class.new(:test, test: 'something')
+
+ expect(entry.value).to eq 'something'
+ end
+ end
+
+ describe '#extensible?' do
+ context 'when entry has inheritance defined' do
+ it 'is extensible' do
+ entry = described_class.new(:test, test: { extends: 'something' })
+
+ expect(entry).to be_extensible
+ end
+ end
+
+ context 'when entry does not have inheritance specified' do
+ it 'is not extensible' do
+ entry = described_class.new(:test, test: { script: 'something' })
+
+ expect(entry).not_to be_extensible
+ end
+ end
+
+ context 'when entry value is not a hash' do
+ it 'is not extensible' do
+ entry = described_class.new(:test, test: 'something')
+
+ expect(entry).not_to be_extensible
+ end
+ end
+ end
+
+ describe '#extends_key' do
+ context 'when entry is extensible' do
+ it 'returns symbolized extends key value' do
+ entry = described_class.new(:test, test: { extends: 'something' })
+
+ expect(entry.extends_key).to eq :something
+ end
+ end
+
+ context 'when entry is not extensible' do
+ it 'returns nil' do
+ entry = described_class.new(:test, test: 'something')
+
+ expect(entry.extends_key).to be_nil
+ end
+ end
+ end
+
+ describe '#ancestors' do
+ let(:parent) do
+ described_class.new(:test, test: { extends: 'something' })
+ end
+
+ let(:child) do
+ described_class.new(:job, { job: { script: 'something' } }, parent)
+ end
+
+ it 'returns ancestors keys' do
+ expect(child.ancestors).to eq [:test]
+ end
+ end
+
+ describe '#base_hash!' do
+ subject { described_class.new(:test, hash) }
+
+ context 'when base hash is not extensible' do
+ let(:hash) do
+ {
+ template: { script: 'rspec' },
+ test: { extends: 'template' }
+ }
+ end
+
+ it 'returns unchanged base hash' do
+ expect(subject.base_hash!).to eq(script: 'rspec')
+ end
+ end
+
+ context 'when base hash is extensible too' do
+ let(:hash) do
+ {
+ first: { script: 'rspec' },
+ second: { extends: 'first' },
+ test: { extends: 'second' }
+ }
+ end
+
+ it 'extends the base hash first' do
+ expect(subject.base_hash!).to eq(extends: 'first', script: 'rspec')
+ end
+
+ it 'mutates original context' do
+ subject.base_hash!
+
+ expect(hash.fetch(:second)).to eq(extends: 'first', script: 'rspec')
+ end
+ end
+ end
+
+ describe '#extend!' do
+ subject { described_class.new(:test, hash) }
+
+ context 'when extending a non-hash value' do
+ let(:hash) do
+ {
+ first: 'my value',
+ test: { extends: 'first' }
+ }
+ end
+
+ it 'raises an error' do
+ expect { subject.extend! }
+ .to raise_error(described_class::InvalidExtensionError,
+ /invalid base hash/)
+ end
+ end
+
+ context 'when extending unknown key' do
+ let(:hash) do
+ { test: { extends: 'something' } }
+ end
+
+ it 'raises an error' do
+ expect { subject.extend! }
+ .to raise_error(described_class::InvalidExtensionError,
+ /unknown key/)
+ end
+ end
+
+ context 'when extending a hash correctly' do
+ let(:hash) do
+ {
+ first: { script: 'my value' },
+ second: { extends: 'first' },
+ test: { extends: 'second' }
+ }
+ end
+
+ let(:result) do
+ {
+ first: { script: 'my value' },
+ second: { extends: 'first', script: 'my value' },
+ test: { extends: 'second', script: 'my value' }
+ }
+ end
+
+ it 'returns extended part of the hash' do
+ expect(subject.extend!).to eq result[:test]
+ end
+
+ it 'mutates original context' do
+ subject.extend!
+
+ expect(hash).to eq result
+ end
+ end
+
+ context 'when hash is not extensible' do
+ let(:hash) do
+ {
+ first: { script: 'my value' },
+ second: { extends: 'first' },
+ test: { value: 'something' }
+ }
+ end
+
+ it 'returns original key value' do
+ expect(subject.extend!).to eq(value: 'something')
+ end
+
+ it 'does not mutate orignal context' do
+ original = hash.deep_dup
+
+ subject.extend!
+
+ expect(hash).to eq original
+ end
+ end
+
+ context 'when circular depenency gets detected' do
+ let(:hash) do
+ { test: { extends: 'test' } }
+ end
+
+ it 'raises an error' do
+ expect { subject.extend! }
+ .to raise_error(described_class::CircularDependencyError,
+ /circular dependency detected/)
+ end
+ end
+
+ context 'when nesting level is too deep' do
+ before do
+ stub_const("#{described_class}::MAX_NESTING_LEVELS", 0)
+ end
+
+ let(:hash) do
+ {
+ first: { script: 'my value' },
+ second: { extends: 'first' },
+ test: { extends: 'second' }
+ }
+ end
+
+ it 'raises an error' do
+ expect { subject.extend! }
+ .to raise_error(described_class::NestingTooDeepError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/extendable_spec.rb b/spec/lib/gitlab/ci/config/extendable_spec.rb
new file mode 100644
index 00000000000..90213f6603d
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/extendable_spec.rb
@@ -0,0 +1,228 @@
+require 'fast_spec_helper'
+
+describe Gitlab::Ci::Config::Extendable do
+ subject { described_class.new(hash) }
+
+ describe '#each' do
+ context 'when there is extendable entry in the hash' do
+ let(:test) do
+ { extends: 'something', only: %w[master] }
+ end
+
+ let(:hash) do
+ { something: { script: 'ls' }, test: test }
+ end
+
+ it 'yields control' do
+ expect { |b| subject.each(&b) }.to yield_control
+ end
+ end
+ end
+
+ describe '#to_hash' do
+ context 'when hash does not contain extensions' do
+ let(:hash) do
+ {
+ test: { script: 'test' },
+ production: {
+ script: 'deploy',
+ only: { variables: %w[$SOMETHING] }
+ }
+ }
+ end
+
+ it 'does not modify the hash' do
+ expect(subject.to_hash).to eq hash
+ end
+ end
+
+ context 'when hash has a single simple extension' do
+ let(:hash) do
+ {
+ something: {
+ script: 'deploy',
+ only: { variables: %w[$SOMETHING] }
+ },
+
+ test: {
+ extends: 'something',
+ script: 'ls',
+ only: { refs: %w[master] }
+ }
+ }
+ end
+
+ it 'extends a hash with a deep reverse merge' do
+ expect(subject.to_hash).to eq(
+ something: {
+ script: 'deploy',
+ only: { variables: %w[$SOMETHING] }
+ },
+
+ test: {
+ extends: 'something',
+ script: 'ls',
+ only: {
+ refs: %w[master],
+ variables: %w[$SOMETHING]
+ }
+ }
+ )
+ end
+ end
+
+ context 'when a hash uses recursive extensions' do
+ let(:hash) do
+ {
+ test: {
+ extends: 'something',
+ script: 'ls',
+ only: { refs: %w[master] }
+ },
+
+ build: {
+ extends: 'something',
+ stage: 'build'
+ },
+
+ deploy: {
+ stage: 'deploy',
+ extends: '.first'
+ },
+
+ something: {
+ extends: '.first',
+ script: 'exec',
+ only: { variables: %w[$SOMETHING] }
+ },
+
+ '.first': {
+ script: 'run',
+ only: { kubernetes: 'active' }
+ }
+ }
+ end
+
+ it 'extends a hash with a deep reverse merge' do
+ expect(subject.to_hash).to eq(
+ '.first': {
+ script: 'run',
+ only: { kubernetes: 'active' }
+ },
+
+ something: {
+ extends: '.first',
+ script: 'exec',
+ only: {
+ kubernetes: 'active',
+ variables: %w[$SOMETHING]
+ }
+ },
+
+ deploy: {
+ script: 'run',
+ stage: 'deploy',
+ only: { kubernetes: 'active' },
+ extends: '.first'
+ },
+
+ build: {
+ extends: 'something',
+ script: 'exec',
+ stage: 'build',
+ only: {
+ kubernetes: 'active',
+ variables: %w[$SOMETHING]
+ }
+ },
+
+ test: {
+ extends: 'something',
+ script: 'ls',
+ only: {
+ refs: %w[master],
+ variables: %w[$SOMETHING],
+ kubernetes: 'active'
+ }
+ }
+ )
+ end
+ end
+
+ context 'when nested circular dependecy has been detected' do
+ let(:hash) do
+ {
+ test: {
+ extends: 'something',
+ script: 'ls',
+ only: { refs: %w[master] }
+ },
+
+ something: {
+ extends: '.first',
+ script: 'deploy',
+ only: { variables: %w[$SOMETHING] }
+ },
+
+ '.first': {
+ extends: 'something',
+ script: 'run',
+ only: { kubernetes: 'active' }
+ }
+ }
+ end
+
+ it 'raises an error about circular dependency' do
+ expect { subject.to_hash }
+ .to raise_error(described_class::Entry::CircularDependencyError)
+ end
+ end
+
+ context 'when circular dependecy to self has been detected' do
+ let(:hash) do
+ {
+ test: {
+ extends: 'test',
+ script: 'ls',
+ only: { refs: %w[master] }
+ }
+ }
+ end
+
+ it 'raises an error about circular dependency' do
+ expect { subject.to_hash }
+ .to raise_error(described_class::Entry::CircularDependencyError)
+ end
+ end
+
+ context 'when invalid extends value is specified' do
+ let(:hash) do
+ { something: { extends: 1, script: 'ls' } }
+ end
+
+ it 'raises an error about invalid extension' do
+ expect { subject.to_hash }
+ .to raise_error(described_class::Entry::InvalidExtensionError)
+ end
+ end
+
+ context 'when extensible entry has non-hash inheritance defined' do
+ let(:hash) do
+ {
+ test: {
+ extends: 'something',
+ script: 'ls',
+ only: { refs: %w[master] }
+ },
+
+ something: 'some text'
+ }
+ end
+
+ it 'raises an error about invalid base' do
+ expect { subject.to_hash }
+ .to raise_error(described_class::Entry::InvalidExtensionError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb
index 2e204da307d..5a78ce783dd 100644
--- a/spec/lib/gitlab/ci/config_spec.rb
+++ b/spec/lib/gitlab/ci/config_spec.rb
@@ -1,4 +1,6 @@
-require 'spec_helper'
+require 'fast_spec_helper'
+
+require_dependency 'active_model'
describe Gitlab::Ci::Config do
let(:config) do
@@ -42,6 +44,36 @@ describe Gitlab::Ci::Config do
end
end
+ context 'when using extendable hash' do
+ let(:yml) do
+ <<-EOS
+ image: ruby:2.2
+
+ rspec:
+ script: rspec
+
+ test:
+ extends: rspec
+ image: ruby:alpine
+ EOS
+ end
+
+ it 'correctly extends the hash' do
+ hash = {
+ image: 'ruby:2.2',
+ rspec: { script: 'rspec' },
+ test: {
+ extends: 'rspec',
+ image: 'ruby:alpine',
+ script: 'rspec'
+ }
+ }
+
+ expect(config).to be_valid
+ expect(config.to_hash).to eq hash
+ end
+ end
+
context 'when config is invalid' do
context 'when yml is incorrect' do
let(:yml) { '// invalid' }
@@ -49,7 +81,7 @@ describe Gitlab::Ci::Config do
describe '.new' do
it 'raises error' do
expect { config }.to raise_error(
- ::Gitlab::Ci::Config::Loader::FormatError,
+ described_class::ConfigError,
/Invalid configuration format/
)
end
@@ -75,5 +107,21 @@ describe Gitlab::Ci::Config do
end
end
end
+
+ context 'when invalid extended hash has been provided' do
+ let(:yml) do
+ <<-EOS
+ test:
+ extends: test
+ script: rspec
+ EOS
+ end
+
+ it 'raises an error' do
+ expect { config }.to raise_error(
+ described_class::ConfigError, /circular dependency detected/
+ )
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index e73cdc54a15..fcbdf71a4e9 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -562,6 +562,58 @@ module Gitlab
end
end
+ context 'when using `extends`' do
+ let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
+
+ subject { config_processor.builds.first }
+
+ context 'when using simple `extends`' do
+ let(:config) do
+ <<~YAML
+ .template:
+ script: test
+
+ rspec:
+ extends: .template
+ image: ruby:alpine
+ YAML
+ end
+
+ it 'correctly extends rspec job' do
+ expect(config_processor.builds).to be_one
+ expect(subject.dig(:commands)).to eq 'test'
+ expect(subject.dig(:options, :image, :name)).to eq 'ruby:alpine'
+ end
+ end
+
+ context 'when using recursive `extends`' do
+ let(:config) do
+ <<~YAML
+ rspec:
+ extends: .test
+ script: rspec
+ when: always
+
+ .template:
+ before_script:
+ - bundle install
+
+ .test:
+ extends: .template
+ script: test
+ image: image:test
+ YAML
+ end
+
+ it 'correctly extends rspec job' do
+ expect(config_processor.builds).to be_one
+ expect(subject.dig(:commands)).to eq "bundle install\nrspec"
+ expect(subject.dig(:options, :image, :name)).to eq 'image:test'
+ expect(subject.dig(:when)).to eq 'always'
+ end
+ end
+ end
+
describe "When" do
%w(on_success on_failure always).each do |when_state|
it "returns #{when_state} when defined" do
@@ -1309,6 +1361,14 @@ module Gitlab
.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
'jobs:rspec:only variables invalid expression syntax')
end
+
+ it 'returns errors if extended hash configuration is invalid' do
+ config = YAML.dump({ rspec: { extends: 'something', script: 'test' } })
+
+ expect { Gitlab::Ci::YamlProcessor.new(config) }
+ .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
+ 'rspec: unknown key in `extends`')
+ end
end
describe "Validate configuration templates" do