diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 913253e4e9225f647ea437f4108b7164b6a52cce..12926bc2379f01d66782f4077cd8627562507b4a 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -607,8 +607,14 @@ def config_processor rescue Gitlab::Ci::YamlProcessor::ValidationError => e self.yaml_errors = e.message nil - rescue - self.yaml_errors = 'Undefined error' + rescue => ex + self.yaml_errors = "Undefined error (#{Labkit::Correlation::CorrelationId.current_id})" + + Gitlab::Sentry.track_acceptable_exception(ex, extra: { + project_id: project.id, + sha: sha, + ci_yaml_file: ci_yaml_file_path + }) nil end end diff --git a/ee/lib/ee/gitlab/ci/config/entry/bridge.rb b/ee/lib/ee/gitlab/ci/config/entry/bridge.rb index 8943f67257c6feeb8a2a38f9a14b8ca1f6a133bc..a505bc9391d6edf0b3d44e74703210ec670a80a1 100644 --- a/ee/lib/ee/gitlab/ci/config/entry/bridge.rb +++ b/ee/lib/ee/gitlab/ci/config/entry/bridge.rb @@ -23,27 +23,35 @@ class Bridge < ::Gitlab::Config::Entry::Node validates :name, presence: true validates :name, type: Symbol - validate do - unless trigger.present? || needs.present? - errors.add(:config, 'should contain either a trigger or a needs:pipeline') - end - end - with_options allow_nil: true do validates :when, inclusion: { in: %w[on_success on_failure always], message: 'should be on_success, on_failure or always' } validates :extends, type: String end + + validate on: :composed do + unless trigger.present? || bridge_needs.present? + errors.add(:config, 'should contain either a trigger or a needs:pipeline') + end + end + + validate on: :composed do + next unless bridge_needs.present? + next if bridge_needs.one? + + errors.add(:config, 'should contain at most one bridge need') + end end entry :trigger, ::EE::Gitlab::Ci::Config::Entry::Trigger, description: 'CI/CD Bridge downstream trigger definition.', inherit: false - entry :needs, ::EE::Gitlab::Ci::Config::Entry::Needs, + entry :needs, ::Gitlab::Ci::Config::Entry::Needs, description: 'CI/CD Bridge needs dependency definition.', - inherit: false + inherit: false, + metadata: { allowed_needs: %i[bridge job] } entry :stage, ::Gitlab::Ci::Config::Entry::Stage, description: 'Pipeline stage this job will be executed into.', @@ -93,6 +101,10 @@ def value except: except_value }.compact end + def bridge_needs + needs_value[:bridge] if needs_value + end + private def overwrite_entry(deps, key, current_entry) diff --git a/ee/lib/ee/gitlab/ci/config/entry/need.rb b/ee/lib/ee/gitlab/ci/config/entry/need.rb new file mode 100644 index 0000000000000000000000000000000000000000..1f34412364ff5b3a50e569de498262e8fc915133 --- /dev/null +++ b/ee/lib/ee/gitlab/ci/config/entry/need.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module Ci + module Config + module Entry + module Need + extend ActiveSupport::Concern + + prepended do + strategy :Bridge, class: EE::Gitlab::Ci::Config::Entry::Need::Bridge, if: -> (config) { config.is_a?(Hash) } + end + + class Bridge < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[pipeline].freeze + attributes :pipeline + + validations do + validates :config, presence: true + validates :config, allowed_keys: ALLOWED_KEYS + validates :pipeline, type: String, presence: true + end + + def type + :bridge + end + end + end + end + end + end + end +end diff --git a/ee/lib/ee/gitlab/ci/config/entry/needs.rb b/ee/lib/ee/gitlab/ci/config/entry/needs.rb deleted file mode 100644 index f27492465cf90014b26b6fac63a7d6cc77c2db8c..0000000000000000000000000000000000000000 --- a/ee/lib/ee/gitlab/ci/config/entry/needs.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module EE - module Gitlab - module Ci - module Config - module Entry - ## - # Entry that represents a cross-project needs dependency. - # - class Needs < ::Gitlab::Config::Entry::Node - include ::Gitlab::Config::Entry::Validatable - include ::Gitlab::Config::Entry::Attributable - - ALLOWED_KEYS = %i[pipeline].freeze - attributes :pipeline - - validations do - validates :config, presence: true - validates :config, allowed_keys: ALLOWED_KEYS - validates :pipeline, type: String, presence: true - end - end - end - end - end - end -end diff --git a/ee/spec/lib/ee/gitlab/ci/config/entry/bridge_spec.rb b/ee/spec/lib/ee/gitlab/ci/config/entry/bridge_spec.rb index 848fec54b86b05abc3a565c925d220cea47b64d6..a4404a6b1c992dfe5bd35b0bc174e973a1670447 100644 --- a/ee/spec/lib/ee/gitlab/ci/config/entry/bridge_spec.rb +++ b/ee/spec/lib/ee/gitlab/ci/config/entry/bridge_spec.rb @@ -88,7 +88,7 @@ describe '#value' do it 'is returns a bridge job configuration' do expect(subject.value).to eq(name: :my_bridge, - needs: { pipeline: 'some/project' }, + needs: { bridge: [{ pipeline: 'some/project' }] }, ignore: false, stage: 'test', only: { refs: %w[branches tags] }) @@ -160,6 +160,50 @@ end end + context 'when bridge has only job needs' do + let(:config) do + { + needs: ['some_job'] + } + end + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + end + + context 'when bridge has bridge and job needs' do + let(:config) do + { + trigger: 'other-project', + needs: ['some_job', { pipeline: 'some/other_project' }] + } + end + + describe '#valid?' do + it { is_expected.to be_valid } + end + end + + context 'when bridge has more than one valid bridge needs' do + let(:config) do + { + trigger: 'other-project', + needs: [{ pipeline: 'some/project' }, { pipeline: 'some/other_project' }] + } + end + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns an error about too many bridge needs' do + expect(subject.errors).to contain_exactly('bridge config should contain at most one bridge need') + end + end + end + context 'when bridge config contains unknown keys' do let(:config) { { unknown: 123 } } diff --git a/ee/spec/lib/ee/gitlab/ci/config/entry/need_spec.rb b/ee/spec/lib/ee/gitlab/ci/config/entry/need_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2883851ce33e6e0cf1ff84589c50d958c78cd26b --- /dev/null +++ b/ee/spec/lib/ee/gitlab/ci/config/entry/need_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::Ci::Config::Entry::Need do + subject { described_class.new(config) } + + context 'when upstream is specified' do + let(:config) { { pipeline: 'some/project' } } + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + it 'returns job needs configuration' do + expect(subject.value).to eq(pipeline: 'some/project') + end + end + end + + context 'when need is empty' do + let(:config) { {} } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'is returns an error about an empty config' do + expect(subject.errors) + .to include("bridge config can't be blank") + end + end + end +end diff --git a/ee/spec/lib/ee/gitlab/ci/config/entry/needs_spec.rb b/ee/spec/lib/ee/gitlab/ci/config/entry/needs_spec.rb index f0c5cddb811d11b0efbea2b5e5c82703f2101517..2df14ae0b4cb12e545255c5ef3a8df313968d104 100644 --- a/ee/spec/lib/ee/gitlab/ci/config/entry/needs_spec.rb +++ b/ee/spec/lib/ee/gitlab/ci/config/entry/needs_spec.rb @@ -1,52 +1,77 @@ # frozen_string_literal: true -require 'fast_spec_helper' -require_dependency 'active_model' +require 'spec_helper' -describe EE::Gitlab::Ci::Config::Entry::Needs do - subject { described_class.new(config) } +describe ::Gitlab::Ci::Config::Entry::Needs do + subject(:needs) { described_class.new(config) } - context 'when upstream config is a non-empty string' do - let(:config) { { pipeline: 'some/project' } } + before do + needs.metadata[:allowed_needs] = %i[job bridge] + end - describe '#valid?' do - it { is_expected.to be_valid } + describe 'validations' do + before do + needs.compose! end - describe '#value' do - it 'is returns a upstream configuration hash' do - expect(subject.value).to eq(pipeline: 'some/project') + context 'when entry config value is correct' do + let(:config) { ['job_name', pipeline: 'some/project'] } + + describe '#valid?' do + it { is_expected.to be_valid } end end - end - context 'when upstream config an empty string' do - let(:config) { '' } + context 'when wrong needs type is used' do + let(:config) { ['job_name', { pipeline: 'some/project' }, 123] } - describe '#valid?' do - it { is_expected.not_to be_valid } - end + describe '#valid?' do + it { is_expected.not_to be_valid } + end - describe '#errors' do - it 'is returns an error about an empty config' do - expect(subject.errors.first) - .to eq("needs config can't be blank") + describe '#errors' do + it 'returns error about incorrect type' do + expect(needs.errors).to contain_exactly( + 'need has an unsupported type') + end end end - end - context 'when upstream configuration is not valid' do - context 'when branch is not provided' do - let(:config) { { pipeline: 123 } } + context 'when bridge needs has wrong attributes' do + let(:config) { ['job_name', project: 'some/project'] } describe '#valid?' do it { is_expected.not_to be_valid } end + end + end - describe '#errors' do - it 'returns an error message' do - expect(subject.errors.first) - .to eq('needs pipeline should be a string') + describe '.compose!' do + context 'when valid job entries composed' do + let(:config) { ['first_job_name', pipeline: 'some/project'] } + + before do + needs.compose! + end + + it 'is valid' do + expect(needs).to be_valid + end + + describe '#value' do + it 'returns key value' do + expect(needs.value).to eq( + job: [{ name: 'first_job_name' }], + bridge: [{ pipeline: 'some/project' }] + ) + end + end + + describe '#descendants' do + it 'creates valid descendant nodes' do + expect(needs.descendants.count).to eq 2 + expect(needs.descendants) + .to all(be_an_instance_of(::Gitlab::Ci::Config::Entry::Need)) end end end diff --git a/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb b/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9502fb9b5214b3e4229cc69cf0972e90c97fd499 --- /dev/null +++ b/ee/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Job do + let(:entry) { described_class.new(config, name: :rspec) } + + describe 'validations' do + before do + entry.compose! + end + + context 'when entry value is not correct' do + context 'when has needs' do + context 'when needs is bridge type' do + let(:config) do + { + script: 'echo', + stage: 'test', + needs: { pipeline: 'some/project' } + } + end + + it 'returns error about invalid needs type' do + expect(entry).not_to be_valid + expect(entry.errors).to contain_exactly('needs config uses invalid types: bridge') + end + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/ci/yaml_processor_spec.rb b/ee/spec/lib/gitlab/ci/yaml_processor_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e782c762daacd735fef3b6ffa39296e6e4a7e537 --- /dev/null +++ b/ee/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::YamlProcessor do + describe 'Bridge Needs' do + let(:config) do + { + build: { stage: 'build', script: 'test' }, + bridge: { stage: 'test', needs: needs } + } + end + + subject { described_class.new(YAML.dump(config)) } + + context 'needs upstream pipeline' do + let(:needs) { { pipeline: 'some/project' } } + + it 'creates jobs with valid specification' do + expect(subject.builds.size).to eq(2) + expect(subject.builds[0]).to eq( + stage: "build", + stage_idx: 1, + name: "build", + options: { + script: ["test"] + }, + when: "on_success", + allow_failure: false, + yaml_variables: [] + ) + expect(subject.builds[1]).to eq( + stage: "test", + stage_idx: 2, + name: "bridge", + options: { + bridge_needs: { pipeline: 'some/project' } + }, + when: "on_success", + allow_failure: false, + yaml_variables: [] + ) + end + end + + context 'needs both job and pipeline' do + let(:needs) { ['build', { pipeline: 'some/project' }] } + + it 'creates jobs with valid specification' do + expect(subject.builds.size).to eq(2) + expect(subject.builds[0]).to eq( + stage: "build", + stage_idx: 1, + name: "build", + options: { + script: ["test"] + }, + when: "on_success", + allow_failure: false, + yaml_variables: [] + ) + expect(subject.builds[1]).to eq( + stage: "test", + stage_idx: 2, + name: "bridge", + options: { + bridge_needs: { pipeline: 'some/project' } + }, + needs_attributes: [ + { name: "build" } + ], + when: "on_success", + allow_failure: false, + yaml_variables: [] + ) + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 1298e2d34628de9b2ac633eac4636661d1763efd..2d5981a42559b8cefc08562663b0b11108fde4b5 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -50,7 +50,6 @@ class Job < ::Gitlab::Config::Entry::Node validates :timeout, duration: { limit: ChronicDuration.output(Project::MAX_BUILD_TIMEOUT) } validates :dependencies, array_of_strings: true - validates :needs, array_of_strings: true validates :extends, array_of_strings_or_string: true validates :rules, array_of_hashes: true end @@ -114,6 +113,11 @@ class Job < ::Gitlab::Config::Entry::Node description: 'List of evaluable Rules to determine job inclusion.', inherit: false + entry :needs, Entry::Needs, + description: 'Needs configuration for this job.', + metadata: { allowed_needs: %i[job] }, + inherit: false + entry :variables, Entry::Variables, description: 'Environment variables available for this job.', inherit: false diff --git a/lib/gitlab/ci/config/entry/need.rb b/lib/gitlab/ci/config/entry/need.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6db546d8ff42ec45b728b2b908e212dd9d3979f --- /dev/null +++ b/lib/gitlab/ci/config/entry/need.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class Need < ::Gitlab::Config::Entry::Simplifiable + strategy :Job, if: -> (config) { config.is_a?(String) } + + class Job < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, presence: true + validates :config, type: String + end + + def type + :job + end + + def value + { name: @config } + end + end + + class UnknownStrategy < ::Gitlab::Config::Entry::Node + def type + end + + def value + end + + def errors + ["#{location} has an unsupported type"] + end + end + end + end + end + end +end + +::Gitlab::Ci::Config::Entry::Need.prepend_if_ee('::EE::Gitlab::Ci::Config::Entry::Need') diff --git a/lib/gitlab/ci/config/entry/needs.rb b/lib/gitlab/ci/config/entry/needs.rb new file mode 100644 index 0000000000000000000000000000000000000000..28452aaaa16b48e0d9d75a773dba7a713b0681df --- /dev/null +++ b/lib/gitlab/ci/config/entry/needs.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a set of needs dependencies. + # + class Needs < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + + validations do + validates :config, presence: true + + validate do + unless config.is_a?(Hash) || config.is_a?(Array) + errors.add(:config, 'can only be a Hash or an Array') + end + end + + validate on: :composed do + extra_keys = value.keys - opt(:allowed_needs) + if extra_keys.any? + errors.add(:config, "uses invalid types: #{extra_keys.join(', ')}") + end + end + end + + def compose!(deps = nil) + super(deps) do + [@config].flatten.each_with_index do |need, index| + @entries[index] = ::Gitlab::Config::Entry::Factory.new(Entry::Need) + .value(need) + .with(key: "need", parent: self, description: "need definition.") # rubocop:disable CodeReuse/ActiveRecord + .create! + end + + @entries.each_value do |entry| + entry.compose!(deps) + end + end + end + + def value + values = @entries.values.select(&:type) + values.group_by(&:type).transform_values do |values| + values.map(&:value) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/normalizer.rb b/lib/gitlab/ci/config/normalizer.rb index 09f9bf5f69f7bc8b41d74e1e242b59994de3f585..e714ef225f5938c36c839e98dd63dc196cdba077 100644 --- a/lib/gitlab/ci/config/normalizer.rb +++ b/lib/gitlab/ci/config/normalizer.rb @@ -18,8 +18,8 @@ def normalize_jobs config[:dependencies] = expand_names(config[:dependencies]) end - if config[:needs] - config[:needs] = expand_names(config[:needs]) + if job_needs = config.dig(:needs, :job) + config[:needs][:job] = expand_needs(job_needs) end config @@ -36,6 +36,22 @@ def expand_names(job_names) end end + def expand_needs(job_needs) + return unless job_needs + + job_needs.flat_map do |job_need| + job_need_name = job_need[:name].to_sym + + if all_job_names = parallelized_jobs[job_need_name] + all_job_names.map do |job_name| + { name: job_name } + end + else + job_need + end + end + end + def parallelized_jobs strong_memoize(:parallelized_jobs) do @jobs_config.each_with_object({}) do |(job_name, config), hash| diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index f6a3abefcfb350d9ff69560a579b3c9473612a8b..c2a55fa8b1b444c494728b74d52f1e0fc0c2424c 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -40,7 +40,7 @@ def build_attributes(name) environment: job[:environment_name], coverage_regex: job[:coverage], yaml_variables: yaml_variables(name), - needs_attributes: job[:needs]&.map { |need| { name: need } }, + needs_attributes: job.dig(:needs, :job), interruptible: job[:interruptible], rules: job[:rules], options: { @@ -59,7 +59,7 @@ def build_attributes(name) instance: job[:instance], start_in: job[:start_in], trigger: job[:trigger], - bridge_needs: job[:needs] + bridge_needs: job.dig(:needs, :bridge)&.first }.compact }.compact end @@ -159,17 +159,19 @@ def validate_job_dependencies!(name, job) end def validate_job_needs!(name, job) - return unless job[:needs] + return unless job.dig(:needs, :job) stage_index = @stages.index(job[:stage]) - job[:needs].each do |need| - raise ValidationError, "#{name} job: undefined need: #{need}" unless @jobs[need.to_sym] + job.dig(:needs, :job).each do |need| + need_job_name = need[:name] - needs_stage_index = @stages.index(@jobs[need.to_sym][:stage]) + raise ValidationError, "#{name} job: undefined need: #{need_job_name}" unless @jobs[need_job_name.to_sym] + + needs_stage_index = @stages.index(@jobs[need_job_name.to_sym][:stage]) unless needs_stage_index.present? && needs_stage_index < stage_index - raise ValidationError, "#{name} job: need #{need} is not defined in prior stages" + raise ValidationError, "#{name} job: need #{need_job_name} is not defined in prior stages" end end end diff --git a/lib/gitlab/config/entry/configurable.rb b/lib/gitlab/config/entry/configurable.rb index b7ec4b7c4f8203f0fdde0213c7f1057d880de458..bda84dc2cff1f8226e85b7f871f8729610bb6334 100644 --- a/lib/gitlab/config/entry/configurable.rb +++ b/lib/gitlab/config/entry/configurable.rb @@ -29,22 +29,24 @@ module Configurable def compose!(deps = nil) return unless valid? - self.class.nodes.each do |key, factory| - # If we override the config type validation - # we can end with different config types like String - next unless config.is_a?(Hash) + super do + self.class.nodes.each do |key, factory| + # If we override the config type validation + # we can end with different config types like String + next unless config.is_a?(Hash) - factory - .value(config[key]) - .with(key: key, parent: self) + factory + .value(config[key]) + .with(key: key, parent: self) - entries[key] = factory.create! - end + entries[key] = factory.create! + end - yield if block_given? + yield if block_given? - entries.each_value do |entry| - entry.compose!(deps) + entries.each_value do |entry| + entry.compose!(deps) + end end end # rubocop: enable CodeReuse/ActiveRecord @@ -67,12 +69,13 @@ def reserved_node_names private # rubocop: disable CodeReuse/ActiveRecord - def entry(key, entry, description: nil, default: nil, inherit: nil, reserved: nil) + def entry(key, entry, description: nil, default: nil, inherit: nil, reserved: nil, metadata: {}) factory = ::Gitlab::Config::Entry::Factory.new(entry) .with(description: description) .with(default: default) .with(inherit: inherit) .with(reserved: reserved) + .metadata(metadata) (@nodes ||= {}).merge!(key.to_sym => factory) end diff --git a/lib/gitlab/config/entry/node.rb b/lib/gitlab/config/entry/node.rb index e014f15fbd8c305aca0017d21906dab20600fc57..84d3409ed91dcb0678a578599d351753d145b66e 100644 --- a/lib/gitlab/config/entry/node.rb +++ b/lib/gitlab/config/entry/node.rb @@ -112,6 +112,10 @@ def self.aspects @aspects ||= [] end + def self.with_aspect(blk) + self.aspects.append(blk) + end + private attr_reader :entries diff --git a/lib/gitlab/config/entry/simplifiable.rb b/lib/gitlab/config/entry/simplifiable.rb index d58aba07d15f82a020b39b7ff34944c0259ea03b..315f1947e2c9eef1069928e2fe5cdade8634bb73 100644 --- a/lib/gitlab/config/entry/simplifiable.rb +++ b/lib/gitlab/config/entry/simplifiable.rb @@ -4,11 +4,11 @@ module Gitlab module Config module Entry class Simplifiable < SimpleDelegator - EntryStrategy = Struct.new(:name, :condition) + EntryStrategy = Struct.new(:name, :klass, :condition) attr_reader :subject - def initialize(config, **metadata) + def initialize(config, **metadata, &blk) unless self.class.const_defined?(:UnknownStrategy) raise ArgumentError, 'UndefinedStrategy not available!' end @@ -19,14 +19,13 @@ def initialize(config, **metadata) entry = self.class.entry_class(strategy) - @subject = entry.new(config, metadata) + @subject = entry.new(config, metadata, &blk) - yield(@subject) if block_given? super(@subject) end def self.strategy(name, **opts) - EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy| + EntryStrategy.new(name, opts.dig(:class), opts.fetch(:if)).tap do |strategy| strategies.append(strategy) end end @@ -37,7 +36,7 @@ def self.strategies def self.entry_class(strategy) if strategy.present? - self.const_get(strategy.name, false) + strategy.klass || self.const_get(strategy.name, false) else self::UnknownStrategy end diff --git a/lib/gitlab/config/entry/validatable.rb b/lib/gitlab/config/entry/validatable.rb index 1c88c68c11cd71904eaec929e0d4f4b546d9b457..45b852dc2e0f29a55f0c09230e8154a69f916e23 100644 --- a/lib/gitlab/config/entry/validatable.rb +++ b/lib/gitlab/config/entry/validatable.rb @@ -7,14 +7,27 @@ module Validatable extend ActiveSupport::Concern def self.included(node) - node.aspects.append -> do - @validator = self.class.validator.new(self) - @validator.validate(:new) + node.with_aspect -> do + validate(:new) end end + def validator + @validator ||= self.class.validator.new(self) + end + + def validate(context = nil) + validator.validate(context) + end + + def compose!(deps = nil, &blk) + super(deps, &blk) + + validate(:composed) + end + def errors - @validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables + validator.messages + descendants.flat_map(&:errors) end class_methods do diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index d3eb5a9663fd90e7a4fe53779e287f03eb7c5229..9fe18caf6898b03d7ef74da1cd05858e0c5dfe09 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -23,7 +23,7 @@ let(:result) do %i[before_script script stage type after_script cache - image services only except rules variables artifacts + image services only except rules needs variables artifacts environment coverage retry] end @@ -384,21 +384,6 @@ end context 'when has needs' do - context 'that are not a array of strings' do - let(:config) do - { - stage: 'test', - script: 'echo', - needs: 'build-job' - } - end - - it 'returns error about invalid type' do - expect(entry).not_to be_valid - expect(entry.errors).to include 'job needs should be an array of strings' - end - end - context 'when have dependencies that are not subset of needs' do let(:config) do { diff --git a/spec/lib/gitlab/ci/config/entry/need_spec.rb b/spec/lib/gitlab/ci/config/entry/need_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d119e60490085b92115ab94736e56779b7ab0669 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/need_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::Ci::Config::Entry::Need do + subject(:need) { described_class.new(config) } + + context 'when job is specified' do + let(:config) { 'job_name' } + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + it 'returns job needs configuration' do + expect(need.value).to eq(name: 'job_name') + end + end + end + + context 'when need is empty' do + let(:config) { '' } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'is returns an error about an empty config' do + expect(need.errors) + .to contain_exactly("job config can't be blank") + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/needs_spec.rb b/spec/lib/gitlab/ci/config/entry/needs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f4a76b52d30c0e2c2f37dc3f569b95a94ea582b2 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/needs_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::Gitlab::Ci::Config::Entry::Needs do + subject(:needs) { described_class.new(config) } + + before do + needs.metadata[:allowed_needs] = %i[job] + end + + describe 'validations' do + before do + needs.compose! + end + + context 'when entry config value is correct' do + let(:config) { ['job_name'] } + + describe '#valid?' do + it { is_expected.to be_valid } + end + end + + context 'when config value has wrong type' do + let(:config) { 123 } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns error about incorrect type' do + expect(needs.errors) + .to include('needs config can only be a hash or an array') + end + end + end + + context 'when wrong needs type is used' do + let(:config) { [123] } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns error about incorrect type' do + expect(needs.errors).to contain_exactly( + 'need has an unsupported type') + end + end + end + end + + describe '.compose!' do + context 'when valid job entries composed' do + let(:config) { %w[first_job_name second_job_name] } + + before do + needs.compose! + end + + describe '#value' do + it 'returns key value' do + expect(needs.value).to eq( + job: [ + { name: 'first_job_name' }, + { name: 'second_job_name' } + ] + ) + end + end + + describe '#descendants' do + it 'creates valid descendant nodes' do + expect(needs.descendants.count).to eq 2 + expect(needs.descendants) + .to all(be_an_instance_of(::Gitlab::Ci::Config::Entry::Need)) + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/normalizer_spec.rb b/spec/lib/gitlab/ci/config/normalizer_spec.rb index 6b766cc37bfcb5a50bee4621371637aab654c590..bf88047838755a37ecd16f84d1b4c577a0979893 100644 --- a/spec/lib/gitlab/ci/config/normalizer_spec.rb +++ b/spec/lib/gitlab/ci/config/normalizer_spec.rb @@ -7,6 +7,16 @@ let(:job_config) { { script: 'rspec', parallel: 5, name: 'rspec' } } let(:config) { { job_name => job_config } } + let(:expanded_job_names) do + [ + "rspec 1/5", + "rspec 2/5", + "rspec 3/5", + "rspec 4/5", + "rspec 5/5" + ] + end + describe '.normalize_jobs' do subject { described_class.new(config).normalize_jobs } @@ -15,9 +25,7 @@ end it 'has parallelized jobs' do - job_names = [:"rspec 1/5", :"rspec 2/5", :"rspec 3/5", :"rspec 4/5", :"rspec 5/5"] - - is_expected.to include(*job_names) + is_expected.to include(*expanded_job_names.map(&:to_sym)) end it 'sets job instance in options' do @@ -43,49 +51,109 @@ let(:job_name) { :"rspec 35/2" } it 'properly parallelizes job names' do - job_names = [:"rspec 35/2 1/5", :"rspec 35/2 2/5", :"rspec 35/2 3/5", :"rspec 35/2 4/5", :"rspec 35/2 5/5"] + job_names = [ + :"rspec 35/2 1/5", + :"rspec 35/2 2/5", + :"rspec 35/2 3/5", + :"rspec 35/2 4/5", + :"rspec 35/2 5/5" + ] is_expected.to include(*job_names) end end - %i[dependencies needs].each do |context| - context "when job has #{context} on parallelized jobs" do + context 'for dependencies' do + context "when job has dependencies on parallelized jobs" do let(:config) do { job_name => job_config, - other_job: { script: 'echo 1', context => [job_name.to_s] } + other_job: { script: 'echo 1', dependencies: [job_name.to_s] } } end - it "parallelizes #{context}" do - job_names = ["rspec 1/5", "rspec 2/5", "rspec 3/5", "rspec 4/5", "rspec 5/5"] - - expect(subject[:other_job][context]).to include(*job_names) + it "parallelizes dependencies" do + expect(subject[:other_job][:dependencies]).to eq(expanded_job_names) end it "does not include original job name in #{context}" do - expect(subject[:other_job][context]).not_to include(job_name) + expect(subject[:other_job][:dependencies]).not_to include(job_name) end end - context "when there are #{context} which are both parallelized and not" do + context "when there are dependencies which are both parallelized and not" do let(:config) do { job_name => job_config, other_job: { script: 'echo 1' }, - final_job: { script: 'echo 1', context => [job_name.to_s, "other_job"] } + final_job: { script: 'echo 1', dependencies: [job_name.to_s, "other_job"] } } end - it "parallelizes #{context}" do + it "parallelizes dependencies" do job_names = ["rspec 1/5", "rspec 2/5", "rspec 3/5", "rspec 4/5", "rspec 5/5"] - expect(subject[:final_job][context]).to include(*job_names) + expect(subject[:final_job][:dependencies]).to include(*job_names) + end + + it "includes the regular job in dependencies" do + expect(subject[:final_job][:dependencies]).to include('other_job') + end + end + end + + context 'for needs' do + let(:expanded_job_attributes) do + expanded_job_names.map do |job_name| + { name: job_name } + end + end + + context "when job has needs on parallelized jobs" do + let(:config) do + { + job_name => job_config, + other_job: { + script: 'echo 1', + needs: { + job: [ + { name: job_name.to_s } + ] + } + } + } + end + + it "parallelizes needs" do + expect(subject.dig(:other_job, :needs, :job)).to eq(expanded_job_attributes) + end + end + + context "when there are dependencies which are both parallelized and not" do + let(:config) do + { + job_name => job_config, + other_job: { + script: 'echo 1' + }, + final_job: { + script: 'echo 1', + needs: { + job: [ + { name: job_name.to_s }, + { name: "other_job" } + ] + } + } + } + end + + it "parallelizes dependencies" do + expect(subject.dig(:final_job, :needs, :job)).to include(*expanded_job_attributes) end - it "includes the regular job in #{context}" do - expect(subject[:final_job][context]).to include('other_job') + it "includes the regular job in dependencies" do + expect(subject.dig(:final_job, :needs, :job)).to include(name: 'other_job') end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index c747ea670bbf23715b9f2dad5255ef4b5f91ad3b..dc7bbc519ee6cd0323969398c1076de491915c66 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -1293,7 +1293,7 @@ module Ci end end - describe "Needs" do + describe "Job Needs" do let(:needs) { } let(:dependencies) { } @@ -1301,6 +1301,7 @@ module Ci { build1: { stage: 'build', script: 'test' }, build2: { stage: 'build', script: 'test' }, + parallel: { stage: 'build', script: 'test', parallel: 2 }, test1: { stage: 'test', script: 'test', needs: needs, dependencies: dependencies }, test2: { stage: 'test', script: 'test' }, deploy: { stage: 'test', script: 'test' } @@ -1317,7 +1318,7 @@ module Ci let(:needs) { %w(build1 build2) } it "does create jobs with valid specification" do - expect(subject.builds.size).to eq(5) + expect(subject.builds.size).to eq(7) expect(subject.builds[0]).to eq( stage: "build", stage_idx: 1, @@ -1329,16 +1330,11 @@ module Ci allow_failure: false, yaml_variables: [] ) - expect(subject.builds[2]).to eq( + expect(subject.builds[4]).to eq( stage: "test", stage_idx: 2, name: "test1", - options: { - script: ["test"], - # This does not make sense, there is a follow-up: - # https://gitlab.com/gitlab-org/gitlab-foss/issues/65569 - bridge_needs: %w[build1 build2] - }, + options: { script: ["test"] }, needs_attributes: [ { name: "build1" }, { name: "build2" } @@ -1350,10 +1346,25 @@ module Ci end end - context 'needs two builds defined as symbols' do - let(:needs) { [:build1, :build2] } + context 'needs parallel job' do + let(:needs) { %w(parallel) } - it { expect { subject }.not_to raise_error } + it "does create jobs with valid specification" do + expect(subject.builds.size).to eq(7) + expect(subject.builds[4]).to eq( + stage: "test", + stage_idx: 2, + name: "test1", + options: { script: ["test"] }, + needs_attributes: [ + { name: "parallel 1/2" }, + { name: "parallel 2/2" } + ], + when: "on_success", + allow_failure: false, + yaml_variables: [] + ) + end end context 'undefined need' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 5e5a94f8cda710c3f857a6e1f65f232b78054462..9295bb993ceb7df19a48447a01e787b99f165926 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2893,6 +2893,25 @@ def create_build(name, stage_idx) it 'contains yaml errors' do expect(pipeline).to have_yaml_errors + expect(pipeline.yaml_errors).to include('contains unknown keys') + end + end + + context 'when pipeline has undefined error' do + let(:pipeline) do + create(:ci_pipeline, config: {}) + end + + it 'contains yaml errors' do + expect(::Gitlab::Ci::YamlProcessor).to receive(:new) + .and_raise(RuntimeError, 'undefined failure') + + expect(Gitlab::Sentry).to receive(:track_acceptable_exception) + .with(be_a(RuntimeError), anything) + .and_call_original + + expect(pipeline).to have_yaml_errors + expect(pipeline.yaml_errors).to include('Undefined error') end end