Commit d318aabd authored by Fabio Pitino's avatar Fabio Pitino Committed by Shinya Maeda

Fix validations when including artifacts

Ensure that validation can deal with `include` values
that are strings as well as array of hashes.
parent e86e63fa
---
title: Validate dependency on job generating a CI config when using dynamic child pipelines
merge_request: 28901
author:
type: added
...@@ -136,12 +136,11 @@ your own script to generate a YAML file, which is then [used to trigger a child ...@@ -136,12 +136,11 @@ your own script to generate a YAML file, which is then [used to trigger a child
This technique can be very powerful in generating pipelines targeting content that changed or to This technique can be very powerful in generating pipelines targeting content that changed or to
build a matrix of targets and architectures. build a matrix of targets and architectures.
In GitLab 12.9, the child pipeline could fail to be created in certain cases, causing the parent pipeline to fail.
This is [resolved in GitLab 12.10](https://gitlab.com/gitlab-org/gitlab/-/issues/209070).
## Limitations ## Limitations
A parent pipeline can trigger many child pipelines, but a child pipeline cannot trigger A parent pipeline can trigger many child pipelines, but a child pipeline cannot trigger
further child pipelines. See the [related issue](https://gitlab.com/gitlab-org/gitlab/issues/29651) further child pipelines. See the [related issue](https://gitlab.com/gitlab-org/gitlab/issues/29651)
for discussion on possible future improvements. for discussion on possible future improvements.
When triggering dynamic child pipelines, if the job containing the CI config artifact is not a predecessor of the
trigger job, the child pipeline will fail to be created, causing also the parent pipeline to fail.
In the future we want to validate the trigger job's dependencies [at the time the parent pipeline is created](https://gitlab.com/gitlab-org/gitlab/-/issues/209070) rather than when the child pipeline is created.
...@@ -15,6 +15,14 @@ module Gitlab ...@@ -15,6 +15,14 @@ module Gitlab
validations do validations do
validates :config, hash_or_string: true validates :config, hash_or_string: true
validates :config, allowed_keys: ALLOWED_KEYS validates :config, allowed_keys: ALLOWED_KEYS
validate do
next unless config.is_a?(Hash)
if config[:artifact] && config[:job].blank?
errors.add(:config, "must specify the job where to fetch the artifact from")
end
end
end end
end end
end end
......
...@@ -142,6 +142,7 @@ module Gitlab ...@@ -142,6 +142,7 @@ module Gitlab
validate_job_stage!(name, job) validate_job_stage!(name, job)
validate_job_dependencies!(name, job) validate_job_dependencies!(name, job)
validate_job_needs!(name, job) validate_job_needs!(name, job)
validate_dynamic_child_pipeline_dependencies!(name, job)
validate_job_environment!(name, job) validate_job_environment!(name, job)
end end
end end
...@@ -163,35 +164,48 @@ module Gitlab ...@@ -163,35 +164,48 @@ module Gitlab
def validate_job_dependencies!(name, job) def validate_job_dependencies!(name, job)
return unless job[:dependencies] return unless job[:dependencies]
stage_index = @stages.index(job[:stage])
job[:dependencies].each do |dependency| job[:dependencies].each do |dependency|
raise ValidationError, "#{name} job: undefined dependency: #{dependency}" unless @jobs[dependency.to_sym] validate_job_dependency!(name, dependency)
end
end
dependency_stage_index = @stages.index(@jobs[dependency.to_sym][:stage]) def validate_dynamic_child_pipeline_dependencies!(name, job)
return unless includes = job.dig(:trigger, :include)
unless dependency_stage_index.present? && dependency_stage_index < stage_index Array(includes).each do |included|
raise ValidationError, "#{name} job: dependency #{dependency} is not defined in prior stages" next unless included.is_a?(Hash)
end next unless dependency = included[:job]
validate_job_dependency!(name, dependency)
end end
end end
def validate_job_needs!(name, job) def validate_job_needs!(name, job)
return unless job.dig(:needs, :job) return unless needs = job.dig(:needs, :job)
stage_index = @stages.index(job[:stage]) needs.each do |need|
validate_job_dependency!(name, need[:name], 'need')
job.dig(:needs, :job).each do |need| end
need_job_name = need[:name] end
raise ValidationError, "#{name} job: undefined need: #{need_job_name}" unless @jobs[need_job_name.to_sym] def validate_job_dependency!(name, dependency, dependency_type = 'dependency')
unless @jobs[dependency.to_sym]
raise ValidationError, "#{name} job: undefined #{dependency_type}: #{dependency}"
end
needs_stage_index = @stages.index(@jobs[need_job_name.to_sym][:stage]) job_stage_index = stage_index(name)
dependency_stage_index = stage_index(dependency)
unless needs_stage_index.present? && needs_stage_index < stage_index # A dependency might be defined later in the configuration
raise ValidationError, "#{name} job: need #{need_job_name} is not defined in prior stages" # with a stage that does not exist
unless dependency_stage_index.present? && dependency_stage_index < job_stage_index
raise ValidationError, "#{name} job: #{dependency_type} #{dependency} is not defined in prior stages"
end end
end end
def stage_index(name)
stage = @jobs.dig(name.to_sym, :stage)
@stages.index(stage)
end end
def validate_job_environment!(name, job) def validate_job_environment!(name, job)
......
# frozen_string_literal: true
require 'spec_helper'
describe ::Gitlab::Ci::Config::Entry::Include do
subject(:include_entry) { described_class.new(config) }
describe 'validations' do
before do
include_entry.compose!
end
context 'when value is a string' do
let(:config) { 'test.yml' }
it { is_expected.to be_valid }
end
context 'when value is hash' do
context 'when using not allowed keys' do
let(:config) do
{ not_allowed: 'key' }
end
it { is_expected.not_to be_valid }
end
context 'when using "local"' do
let(:config) { { local: 'test.yml' } }
it { is_expected.to be_valid }
end
context 'when using "file"' do
let(:config) { { file: 'test.yml' } }
it { is_expected.to be_valid }
end
context 'when using "template"' do
let(:config) { { template: 'test.yml' } }
it { is_expected.to be_valid }
end
context 'when using "artifact"' do
context 'and specifying "job"' do
let(:config) { { artifact: 'test.yml', job: 'generator' } }
it { is_expected.to be_valid }
end
context 'without "job"' do
let(:config) { { artifact: 'test.yml' } }
it { is_expected.not_to be_valid }
it 'has specific error' do
expect(include_entry.errors)
.to include('include config must specify the job where to fetch the artifact from')
end
end
end
end
context 'when value is something else' do
let(:config) { 123 }
it { is_expected.not_to be_valid }
end
end
end
...@@ -2052,6 +2052,54 @@ module Gitlab ...@@ -2052,6 +2052,54 @@ module Gitlab
end end
end end
describe 'with parent-child pipeline' do
context 'when artifact and job are specified' do
let(:config) do
YAML.dump({
build1: { stage: 'build', script: 'test' },
test1: { stage: 'test', trigger: {
include: [{ artifact: 'generated.yml', job: 'build1' }]
} }
})
end
it { expect { subject }.not_to raise_error }
end
context 'when job is not specified specified while artifact is' do
let(:config) do
YAML.dump({
build1: { stage: 'build', script: 'test' },
test1: { stage: 'test', trigger: {
include: [{ artifact: 'generated.yml' }]
} }
})
end
it do
expect { subject }.to raise_error(
described_class::ValidationError,
/include config must specify the job where to fetch the artifact from/)
end
end
context 'when include is a string' do
let(:config) do
YAML.dump({
build1: { stage: 'build', script: 'test' },
test1: {
stage: 'test',
trigger: {
include: 'generated.yml'
}
}
})
end
it { expect { subject }.not_to raise_error }
end
end
describe "Error handling" do describe "Error handling" do
it "fails to parse YAML" do it "fails to parse YAML" do
expect do expect do
......
...@@ -18,25 +18,10 @@ describe Ci::CreatePipelineService, '#execute' do ...@@ -18,25 +18,10 @@ describe Ci::CreatePipelineService, '#execute' do
before do before do
project.add_developer(user) project.add_developer(user)
stub_ci_pipeline_to_return_yaml_file stub_ci_pipeline_yaml_file(config)
end
describe 'child pipeline triggers' do
before do
stub_ci_pipeline_yaml_file <<~YAML
test:
script: rspec
deploy:
variables:
CROSS: downstream
stage: deploy
trigger:
include:
- local: path/to/child.yml
YAML
end end
shared_examples 'successful creation' do
it 'creates bridge jobs correctly' do it 'creates bridge jobs correctly' do
pipeline = create_pipeline! pipeline = create_pipeline!
...@@ -48,21 +33,57 @@ describe Ci::CreatePipelineService, '#execute' do ...@@ -48,21 +33,57 @@ describe Ci::CreatePipelineService, '#execute' do
expect(bridge).to be_a Ci::Bridge expect(bridge).to be_a Ci::Bridge
expect(bridge.stage).to eq 'deploy' expect(bridge.stage).to eq 'deploy'
expect(pipeline.statuses).to match_array [test, bridge] expect(pipeline.statuses).to match_array [test, bridge]
expect(bridge.options).to eq( expect(bridge.options).to eq(expected_bridge_options)
'trigger' => { 'include' => [{ 'local' => 'path/to/child.yml' }] }
)
expect(bridge.yaml_variables) expect(bridge.yaml_variables)
.to include(key: 'CROSS', value: 'downstream', public: true) .to include(key: 'CROSS', value: 'downstream', public: true)
end end
end end
shared_examples 'creation failure' do
it 'returns errors' do
pipeline = create_pipeline!
expect(pipeline.errors.full_messages.first).to match(expected_error)
expect(pipeline.failure_reason).to eq 'config_error'
expect(pipeline).to be_persisted
expect(pipeline.status).to eq 'failed'
end
end
describe 'child pipeline triggers' do describe 'child pipeline triggers' do
context 'when YAML is valid' do let(:config) do
before do <<~YAML
stub_ci_pipeline_yaml_file <<~YAML
test: test:
script: rspec script: rspec
deploy:
variables:
CROSS: downstream
stage: deploy
trigger:
include:
- local: path/to/child.yml
YAML
end
it_behaves_like 'successful creation' do
let(:expected_bridge_options) do
{
'trigger' => {
'include' => [
{ 'local' => 'path/to/child.yml' }
]
}
}
end
end
end
describe 'child pipeline triggers' do
context 'when YAML is valid' do
let(:config) do
<<~YAML
test:
script: rspec
deploy: deploy:
variables: variables:
CROSS: downstream CROSS: downstream
...@@ -73,33 +94,79 @@ describe Ci::CreatePipelineService, '#execute' do ...@@ -73,33 +94,79 @@ describe Ci::CreatePipelineService, '#execute' do
YAML YAML
end end
it 'creates bridge jobs correctly' do it_behaves_like 'successful creation' do
pipeline = create_pipeline! let(:expected_bridge_options) do
{
'trigger' => {
'include' => [
{ 'local' => 'path/to/child.yml' }
]
}
}
end
end
test = pipeline.statuses.find_by(name: 'test') context 'when trigger:include is specified as a string' do
bridge = pipeline.statuses.find_by(name: 'deploy') let(:config) do
<<~YAML
test:
script: rspec
deploy:
variables:
CROSS: downstream
stage: deploy
trigger:
include: path/to/child.yml
YAML
end
expect(pipeline).to be_persisted it_behaves_like 'successful creation' do
expect(test).to be_a Ci::Build let(:expected_bridge_options) do
expect(bridge).to be_a Ci::Bridge {
expect(bridge.stage).to eq 'deploy' 'trigger' => {
expect(pipeline.statuses).to match_array [test, bridge] 'include' => 'path/to/child.yml'
expect(bridge.options).to eq( }
'trigger' => { 'include' => [{ 'local' => 'path/to/child.yml' }] } }
) end
expect(bridge.yaml_variables)
.to include(key: 'CROSS', value: 'downstream', public: true)
end end
end end
context 'when YAML is invalid' do context 'when trigger:include is specified as array of strings' do
let(:config) do let(:config) do
<<~YAML
test:
script: rspec
deploy:
variables:
CROSS: downstream
stage: deploy
trigger:
include:
- path/to/child.yml
- path/to/child2.yml
YAML
end
it_behaves_like 'successful creation' do
let(:expected_bridge_options) do
{ {
'trigger' => {
'include' => ['path/to/child.yml', 'path/to/child2.yml']
}
}
end
end
end
end
context 'when limit of includes is reached' do
let(:config) do
YAML.dump({
test: { script: 'rspec' }, test: { script: 'rspec' },
deploy: { deploy: {
trigger: { include: included_files } trigger: { include: included_files }
} }
} })
end end
let(:included_files) do let(:included_files) do
...@@ -112,17 +179,46 @@ describe Ci::CreatePipelineService, '#execute' do ...@@ -112,17 +179,46 @@ describe Ci::CreatePipelineService, '#execute' do
Gitlab::Ci::Config::Entry::Trigger::ComplexTrigger::SameProjectTrigger::INCLUDE_MAX_SIZE Gitlab::Ci::Config::Entry::Trigger::ComplexTrigger::SameProjectTrigger::INCLUDE_MAX_SIZE
end end
before do it_behaves_like 'creation failure' do
stub_ci_pipeline_yaml_file(YAML.dump(config)) let(:expected_error) { /trigger:include config is too long/ }
end
end end
it 'returns errors' do context 'when including configs from artifact' do
pipeline = create_pipeline! context 'when specified dependency is in the wrong order' do
let(:config) do
<<~YAML
test:
trigger:
include:
- job: generator
artifact: 'generated.yml'
generator:
stage: 'deploy'
script: 'generator'
YAML
end
expect(pipeline.errors.full_messages.first).to match(/trigger:include config is too long/) it_behaves_like 'creation failure' do
expect(pipeline.failure_reason).to eq 'config_error' let(:expected_error) { /test job: dependency generator is not defined in prior stages/ }
expect(pipeline).to be_persisted end
expect(pipeline.status).to eq 'failed' end
context 'when specified dependency is missing :job key' do
let(:config) do
<<~YAML
test:
trigger:
include:
- artifact: 'generated.yml'
YAML
end
it_behaves_like 'creation failure' do
let(:expected_error) do
/include config must specify the job where to fetch the artifact from/
end
end
end end
end end
end end
......
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