Commit 42b82a9a authored by Marius Bobin's avatar Marius Bobin Committed by Grzegorz Bizon

Add syntax definition for matrix style builds

This commit adds the entities classes required to parse and validate
matrix style builds.
parent e27b4e41
...@@ -64,7 +64,7 @@ module Ci ...@@ -64,7 +64,7 @@ module Ci
variables.append(key: 'CI_PIPELINE_TRIGGERED', value: 'true') if trigger_request variables.append(key: 'CI_PIPELINE_TRIGGERED', value: 'true') if trigger_request
variables.append(key: 'CI_NODE_INDEX', value: self.options[:instance].to_s) if self.options&.include?(:instance) variables.append(key: 'CI_NODE_INDEX', value: self.options[:instance].to_s) if self.options&.include?(:instance)
variables.append(key: 'CI_NODE_TOTAL', value: (self.options&.dig(:parallel) || 1).to_s) variables.append(key: 'CI_NODE_TOTAL', value: ci_node_total_value.to_s)
# legacy variables # legacy variables
variables.append(key: 'CI_BUILD_NAME', value: name) variables.append(key: 'CI_BUILD_NAME', value: name)
...@@ -96,5 +96,13 @@ module Ci ...@@ -96,5 +96,13 @@ module Ci
def secret_project_variables(environment: persisted_environment) def secret_project_variables(environment: persisted_environment)
project.ci_variables_for(ref: git_ref, environment: environment) project.ci_variables_for(ref: git_ref, environment: environment)
end end
private
def ci_node_total_value
parallel = self.options&.dig(:parallel)
parallel = parallel.dig(:total) if parallel.is_a?(Hash)
parallel || 1
end
end end
end end
---
title: Define matrix builds for more complex pipelines
merge_request: 33705
author:
type: added
...@@ -3398,6 +3398,48 @@ Please be aware that semaphore_test_boosters reports usages statistics to the au ...@@ -3398,6 +3398,48 @@ Please be aware that semaphore_test_boosters reports usages statistics to the au
You can then navigate to the **Jobs** tab of a new pipeline build and see your RSpec You can then navigate to the **Jobs** tab of a new pipeline build and see your RSpec
job split into three separate jobs. job split into three separate jobs.
#### Parallel `matrix` jobs
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15356) in GitLab 13.2.
`matrix:` allows you to configure different variables for jobs that are running in parallel.
There can be from 2 to 50 jobs.
Every job gets the same `CI_NODE_TOTAL` [environment variable](../variables/README.md#predefined-environment-variables) value, and a unique `CI_NODE_INDEX` value.
```yaml
deploystacks:
stage: deploy
script:
- bin/deploy
parallel:
matrix:
- PROVIDER: aws
STACK:
- monitoring
- app1
- app2
- PROVIDER: ovh
STACK: [monitoring, backup, app]
- PROVIDER: [gcp, vultr]
STACK: [data, processing]
```
This generates 10 parallel `deploystacks` jobs, each with different values for `PROVIDER` and `STACK`:
```plaintext
deploystacks 1/10 with PROVIDER=aws and STACK=monitoring
deploystacks 2/10 with PROVIDER=aws and STACK=app1
deploystacks 3/10 with PROVIDER=aws and STACK=app2
deploystacks 4/10 with PROVIDER=ovh and STACK=monitoring
deploystacks 5/10 with PROVIDER=ovh and STACK=backup
deploystacks 6/10 with PROVIDER=ovh and STACK=app
deploystacks 7/10 with PROVIDER=gcp and STACK=data
deploystacks 8/10 with PROVIDER=gcp and STACK=processing
deploystacks 9/10 with PROVIDER=vultr and STACK=data
deploystacks 10/10 with PROVIDER=vultr and STACK=processing
```
### `trigger` ### `trigger`
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8997) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.8. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8997) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.8.
......
...@@ -32,9 +32,6 @@ module Gitlab ...@@ -32,9 +32,6 @@ module Gitlab
with_options allow_nil: true do with_options allow_nil: true do
validates :allow_failure, boolean: true validates :allow_failure, boolean: true
validates :parallel, numericality: { only_integer: true,
greater_than_or_equal_to: 2,
less_than_or_equal_to: 50 }
validates :when, inclusion: { validates :when, inclusion: {
in: ALLOWED_WHEN, in: ALLOWED_WHEN,
message: "should be one of: #{ALLOWED_WHEN.join(', ')}" message: "should be one of: #{ALLOWED_WHEN.join(', ')}"
...@@ -124,6 +121,10 @@ module Gitlab ...@@ -124,6 +121,10 @@ module Gitlab
description: 'This job will produce a release.', description: 'This job will produce a release.',
inherit: false inherit: false
entry :parallel, Entry::Product::Parallel,
description: 'Parallel configuration for this job.',
inherit: false
attributes :script, :tags, :allow_failure, :when, :dependencies, attributes :script, :tags, :allow_failure, :when, :dependencies,
:needs, :retry, :parallel, :start_in, :needs, :retry, :parallel, :start_in,
:interruptible, :timeout, :resource_group, :release :interruptible, :timeout, :resource_group, :release
...@@ -174,7 +175,7 @@ module Gitlab ...@@ -174,7 +175,7 @@ module Gitlab
environment_name: environment_defined? ? environment_value[:name] : nil, environment_name: environment_defined? ? environment_value[:name] : nil,
coverage: coverage_defined? ? coverage_value : nil, coverage: coverage_defined? ? coverage_value : nil,
retry: retry_defined? ? retry_value : nil, retry: retry_defined? ? retry_value : nil,
parallel: has_parallel? ? parallel.to_i : nil, parallel: has_parallel? ? parallel_value : nil,
interruptible: interruptible_defined? ? interruptible_value : nil, interruptible: interruptible_defined? ? interruptible_value : nil,
timeout: has_timeout? ? ChronicDuration.parse(timeout.to_s) : nil, timeout: has_timeout? ? ChronicDuration.parse(timeout.to_s) : nil,
artifacts: artifacts_value, artifacts: artifacts_value,
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents matrix style parallel builds.
#
module Product
class Matrix < ::Gitlab::Config::Entry::Node
include ::Gitlab::Utils::StrongMemoize
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
validations do
validates :config, array_of_hashes: true
validate on: :composed do
limit = Entry::Product::Parallel::PARALLEL_LIMIT
if number_of_generated_jobs > limit
errors.add(:config, "generates too many jobs (maximum is #{limit})")
end
end
end
def compose!(deps = nil)
super(deps) do
@config.each_with_index do |variables, index|
@entries[index] = ::Gitlab::Config::Entry::Factory.new(Entry::Product::Variables)
.value(variables)
.with(parent: self, description: 'matrix variables definition.') # rubocop:disable CodeReuse/ActiveRecord
.create!
end
@entries.each_value do |entry|
entry.compose!(deps)
end
end
end
def value
strong_memoize(:value) do
@entries.values.map(&:value)
end
end
# rubocop:disable CodeReuse/ActiveRecord
def number_of_generated_jobs
value.sum do |config|
config.values.reduce(1) { |acc, values| acc * values.size }
end
end
# rubocop:enable CodeReuse/ActiveRecord
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents a parallel job config.
#
module Product
class Parallel < ::Gitlab::Config::Entry::Simplifiable
strategy :ParallelBuilds, if: -> (config) { config.is_a?(Numeric) }
strategy :MatrixBuilds, if: -> (config) { ::Gitlab::Ci::Features.parallel_matrix_enabled? && config.is_a?(Hash) }
PARALLEL_LIMIT = 50
class ParallelBuilds < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, numericality: { only_integer: true,
greater_than_or_equal_to: 2,
less_than_or_equal_to: Entry::Product::Parallel::PARALLEL_LIMIT },
allow_nil: true
end
def value
{ number: super.to_i }
end
end
class MatrixBuilds < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Attributable
include ::Gitlab::Config::Entry::Configurable
PERMITTED_KEYS = %i[matrix].freeze
validations do
validates :config, allowed_keys: PERMITTED_KEYS
validates :config, required_keys: PERMITTED_KEYS
end
entry :matrix, Entry::Product::Matrix,
description: 'Variables definition for matrix builds'
end
class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors
["#{location} should be an integer or a hash"]
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents variables for parallel matrix builds.
#
module Product
class Variables < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, variables: { array_values: true }
validates :config, length: {
minimum: 2,
too_short: 'requires at least %{count} items'
}
end
def self.default(**)
{}
end
def value
@config
.map { |key, value| [key.to_s, Array(value).map(&:to_s)] }
.to_h
end
end
end
end
end
end
end
...@@ -32,7 +32,7 @@ module Gitlab ...@@ -32,7 +32,7 @@ module Gitlab
return unless job_names return unless job_names
job_names.flat_map do |job_name| job_names.flat_map do |job_name|
parallelized_jobs[job_name.to_sym] || job_name parallelized_jobs[job_name.to_sym]&.map(&:name) || job_name
end end
end end
...@@ -42,10 +42,8 @@ module Gitlab ...@@ -42,10 +42,8 @@ module Gitlab
job_needs.flat_map do |job_need| job_needs.flat_map do |job_need|
job_need_name = job_need[:name].to_sym job_need_name = job_need[:name].to_sym
if all_job_names = parallelized_jobs[job_need_name] if all_jobs = parallelized_jobs[job_need_name]
all_job_names.map do |job_name| all_jobs.map { |job| job_need.merge(name: job.name) }
job_need.merge(name: job_name)
end
else else
job_need job_need
end end
...@@ -57,7 +55,7 @@ module Gitlab ...@@ -57,7 +55,7 @@ module Gitlab
@jobs_config.each_with_object({}) do |(job_name, config), hash| @jobs_config.each_with_object({}) do |(job_name, config), hash|
next unless config[:parallel] next unless config[:parallel]
hash[job_name] = self.class.parallelize_job_names(job_name, config[:parallel]) hash[job_name] = parallelize_job_config(job_name, config[:parallel])
end end
end end
end end
...@@ -65,9 +63,9 @@ module Gitlab ...@@ -65,9 +63,9 @@ module Gitlab
def expand_parallelize_jobs def expand_parallelize_jobs
@jobs_config.each_with_object({}) do |(job_name, config), hash| @jobs_config.each_with_object({}) do |(job_name, config), hash|
if parallelized_jobs.key?(job_name) if parallelized_jobs.key?(job_name)
parallelized_jobs[job_name].each_with_index do |name, index| parallelized_jobs[job_name].each do |job|
hash[name.to_sym] = hash[job.name.to_sym] =
yield(name, config.merge(name: name, instance: index + 1)) yield(job.name, config.deep_merge(job.attributes))
end end
else else
hash[job_name] = yield(job_name, config) hash[job_name] = yield(job_name, config)
...@@ -75,8 +73,8 @@ module Gitlab ...@@ -75,8 +73,8 @@ module Gitlab
end end
end end
def self.parallelize_job_names(name, total) def parallelize_job_config(name, config)
Array.new(total) { |index| "#{name} #{index + 1}/#{total}" } Normalizer::Factory.new(name, config).create
end end
end end
end end
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
class Normalizer
class Factory
include Gitlab::Utils::StrongMemoize
def initialize(name, config)
@name = name
@config = config
end
def create
return [] unless strategy
strategy.build_from(@name, @config)
end
private
def strategy
strong_memoize(:strategy) do
strategies.find do |strategy|
strategy.applies_to?(@config)
end
end
end
def strategies
if ::Gitlab::Ci::Features.parallel_matrix_enabled?
[NumberStrategy, MatrixStrategy]
else
[NumberStrategy]
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
class Normalizer
class MatrixStrategy
class << self
def applies_to?(config)
config.is_a?(Hash) && config.key?(:matrix)
end
def build_from(job_name, initial_config)
config = expand(initial_config[:matrix])
total = config.size
config.map.with_index do |vars, index|
new(job_name, index.next, vars, total)
end
end
# rubocop: disable CodeReuse/ActiveRecord
def expand(config)
config.flat_map do |config|
values = config.values
values[0]
.product(*values.from(1))
.map { |vals| config.keys.zip(vals).to_h }
end
end
# rubocop: enable CodeReuse/ActiveRecord
end
def initialize(job_name, instance, variables, total)
@job_name = job_name
@instance = instance
@variables = variables.to_h
@total = total
end
def attributes
{
name: name,
instance: instance,
variables: variables,
parallel: { total: total }
}
end
def name_with_details
vars = variables.map { |key, value| "#{key}=#{value}"}.join('; ')
"#{job_name} (#{vars})"
end
def name
"#{job_name} #{instance}/#{total}"
end
private
attr_reader :job_name, :instance, :variables, :total
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
class Normalizer
class NumberStrategy
class << self
def applies_to?(config)
config.is_a?(Integer) || config.is_a?(Hash) && config.key?(:number)
end
def build_from(job_name, config)
total = config.is_a?(Hash) ? config[:number] : config
Array.new(total) do |index|
new(job_name, index.next, total)
end
end
end
def initialize(job_name, instance, total)
@job_name = job_name
@instance = instance
@total = total
end
def attributes
{
name: name,
instance: instance,
parallel: { total: total }
}
end
def name
"#{job_name} #{instance}/#{total}"
end
private
attr_reader :job_name, :instance, :total
end
end
end
end
end
...@@ -66,6 +66,10 @@ module Gitlab ...@@ -66,6 +66,10 @@ module Gitlab
::Feature.enabled?(:destroy_only_unlocked_expired_artifacts, default_enabled: false) ::Feature.enabled?(:destroy_only_unlocked_expired_artifacts, default_enabled: false)
end end
def self.parallel_matrix_enabled?
::Feature.enabled?(:ci_parallel_matrix_enabled, default_enabled: true)
end
def self.bulk_insert_on_create?(project) def self.bulk_insert_on_create?(project)
::Feature.enabled?(:ci_bulk_insert_on_create, project, default_enabled: true) ::Feature.enabled?(:ci_bulk_insert_on_create, project, default_enabled: true)
end end
......
...@@ -30,10 +30,18 @@ module Gitlab ...@@ -30,10 +30,18 @@ module Gitlab
end end
def validate_variables(variables) def validate_variables(variables)
variables.is_a?(Hash) && variables.flatten.all?(&method(:validate_alphanumeric))
end
def validate_array_value_variables(variables)
variables.is_a?(Hash) && variables.is_a?(Hash) &&
variables.flatten.all? do |value| variables.keys.all?(&method(:validate_alphanumeric)) &&
validate_string(value) || validate_integer(value) variables.values.all?(&:present?) &&
end variables.values.flatten(1).all?(&method(:validate_alphanumeric))
end
def validate_alphanumeric(value)
validate_string(value) || validate_integer(value)
end end
def validate_integer(value) def validate_integer(value)
......
...@@ -272,10 +272,24 @@ module Gitlab ...@@ -272,10 +272,24 @@ module Gitlab
include LegacyValidationHelpers include LegacyValidationHelpers
def validate_each(record, attribute, value) def validate_each(record, attribute, value)
if options[:array_values]
validate_key_array_values(record, attribute, value)
else
validate_key_values(record, attribute, value)
end
end
def validate_key_values(record, attribute, value)
unless validate_variables(value) unless validate_variables(value)
record.errors.add(attribute, 'should be a hash of key value pairs') record.errors.add(attribute, 'should be a hash of key value pairs')
end end
end end
def validate_key_array_values(record, attribute, value)
unless validate_array_value_variables(value)
record.errors.add(attribute, 'should be a hash of key value pairs, value can be an array')
end
end
end end
class ExpressionValidator < ActiveModel::EachValidator class ExpressionValidator < ActiveModel::EachValidator
......
...@@ -30,7 +30,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do ...@@ -30,7 +30,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
%i[before_script script stage type after_script cache %i[before_script script stage type after_script cache
image services only except rules needs variables artifacts image services only except rules needs variables artifacts
environment coverage retry interruptible timeout release tags environment coverage retry interruptible timeout release tags
inherit] inherit parallel]
end end
it { is_expected.to include(*result) } it { is_expected.to include(*result) }
...@@ -202,56 +202,47 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do ...@@ -202,56 +202,47 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
context 'when parallel value is not correct' do context 'when parallel value is not correct' do
context 'when it is not a numeric value' do context 'when it is not a numeric value' do
let(:config) { { parallel: true } } let(:config) { { script: 'echo', parallel: true } }
it 'returns error about invalid type' do it 'returns error about invalid type' do
expect(entry).not_to be_valid expect(entry).not_to be_valid
expect(entry.errors).to include 'job parallel is not a number' expect(entry.errors).to include 'parallel should be an integer or a hash'
end end
end end
context 'when it is lower than two' do context 'when it is lower than two' do
let(:config) { { parallel: 1 } } let(:config) { { script: 'echo', parallel: 1 } }
it 'returns error about value too low' do it 'returns error about value too low' do
expect(entry).not_to be_valid expect(entry).not_to be_valid
expect(entry.errors) expect(entry.errors)
.to include 'job parallel must be greater than or equal to 2' .to include 'parallel config must be greater than or equal to 2'
end end
end end
context 'when it is bigger than 50' do context 'when it is an empty hash' do
let(:config) { { parallel: 51 } } let(:config) { { script: 'echo', parallel: {} } }
it 'returns error about value too high' do it 'returns error about missing matrix' do
expect(entry).not_to be_valid expect(entry).not_to be_valid
expect(entry.errors) expect(entry.errors)
.to include 'job parallel must be less than or equal to 50' .to include 'parallel config missing required keys: matrix'
end end
end end
end
context 'when it is not an integer' do context 'when it uses both "when:" and "rules:"' do
let(:config) { { parallel: 1.5 } } let(:config) do
{
it 'returns error about wrong value' do script: 'echo',
expect(entry).not_to be_valid when: 'on_failure',
expect(entry.errors).to include 'job parallel must be an integer' rules: [{ if: '$VARIABLE', when: 'on_success' }]
end }
end end
context 'when it uses both "when:" and "rules:"' do it 'returns an error about when: being combined with rules' do
let(:config) do expect(entry).not_to be_valid
{ expect(entry.errors).to include 'job config key may not be used with `rules`: when'
script: 'echo',
when: 'on_failure',
rules: [{ if: '$VARIABLE', when: 'on_success' }]
}
end
it 'returns an error about when: being combined with rules' do
expect(entry).not_to be_valid
expect(entry.errors).to include 'job config key may not be used with `rules`: when'
end
end end
end end
......
# frozen_string_literal: true
require 'fast_spec_helper'
require_dependency 'active_model'
RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Matrix do
subject(:matrix) { described_class.new(config) }
describe 'validations' do
before do
matrix.compose!
end
context 'when entry config value is correct' do
let(:config) do
[
{ 'VAR_1' => [1, 2, 3], 'VAR_2' => [4, 5, 6] },
{ 'VAR_3' => %w[a b], 'VAR_4' => %w[c d] }
]
end
describe '#valid?' do
it { is_expected.to be_valid }
end
end
context 'when entry config generates too many jobs' do
let(:config) do
[
{
'VAR_1' => (1..10).to_a,
'VAR_2' => (11..20).to_a
}
]
end
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
it 'returns error about too many jobs' do
expect(matrix.errors)
.to include('matrix config generates too many jobs (maximum is 50)')
end
end
end
context 'when entry config has only one variable' do
let(:config) do
[
{
'VAR_1' => %w[test]
}
]
end
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
it 'returns error about too many jobs' do
expect(matrix.errors)
.to include('variables config requires at least 2 items')
end
end
describe '#value' do
before do
matrix.compose!
end
it 'returns the value without raising an error' do
expect(matrix.value).to eq([{ 'VAR_1' => ['test'] }])
end
end
end
context 'when config value has wrong type' do
let(:config) { {} }
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
it 'returns error about incorrect type' do
expect(matrix.errors)
.to include('matrix config should be an array of hashes')
end
end
end
end
describe '.compose!' do
context 'when valid job entries composed' do
let(:config) do
[
{ PROVIDER: 'aws', STACK: %w[monitoring app1 app2] },
{ STACK: %w[monitoring backup app], PROVIDER: 'ovh' },
{ PROVIDER: 'gcp', STACK: %w[data processing], ARGS: 'normal' },
{ PROVIDER: 'vultr', STACK: 'data', ARGS: 'store' }
]
end
before do
matrix.compose!
end
describe '#value' do
it 'returns key value' do
expect(matrix.value).to match(
[
{ 'PROVIDER' => %w[aws], 'STACK' => %w[monitoring app1 app2] },
{ 'PROVIDER' => %w[ovh], 'STACK' => %w[monitoring backup app] },
{ 'ARGS' => %w[normal], 'PROVIDER' => %w[gcp], 'STACK' => %w[data processing] },
{ 'ARGS' => %w[store], 'PROVIDER' => %w[vultr], 'STACK' => %w[data] }
]
)
end
end
describe '#descendants' do
it 'creates valid descendant nodes' do
expect(matrix.descendants.count).to eq(config.size)
expect(matrix.descendants)
.to all(be_an_instance_of(::Gitlab::Ci::Config::Entry::Product::Variables))
end
end
end
context 'with empty config' do
let(:config) { [] }
before do
matrix.compose!
end
describe '#value' do
it 'returns empty value' do
expect(matrix.value).to eq([])
end
end
end
end
describe '#number_of_generated_jobs' do
before do
matrix.compose!
end
subject { matrix.number_of_generated_jobs }
context 'with empty config' do
let(:config) { [] }
it { is_expected.to be_zero }
end
context 'with only one variable' do
let(:config) do
[{ 'VAR_1' => (1..10).to_a }]
end
it { is_expected.to eq(10) }
end
context 'with two variables' do
let(:config) do
[{ 'VAR_1' => (1..10).to_a, 'VAR_2' => (1..5).to_a }]
end
it { is_expected.to eq(50) }
end
context 'with two sets of variables' do
let(:config) do
[
{ 'VAR_1' => (1..10).to_a, 'VAR_2' => (1..5).to_a },
{ 'VAR_3' => (1..2).to_a, 'VAR_4' => (1..3).to_a }
]
end
it { is_expected.to eq(56) }
end
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
require_dependency 'active_model'
RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do
subject(:parallel) { described_class.new(config) }
context 'with invalid config' do
shared_examples 'invalid config' do |error_message|
describe '#valid?' do
it { is_expected.not_to be_valid }
end
describe '#errors' do
it 'returns error about invalid type' do
expect(parallel.errors).to match(a_collection_including(error_message))
end
end
end
context 'when it is not a numeric value' do
let(:config) { true }
it_behaves_like 'invalid config', /should be an integer or a hash/
end
context 'when it is lower than two' do
let(:config) { 1 }
it_behaves_like 'invalid config', /must be greater than or equal to 2/
end
context 'when it is bigger than 50' do
let(:config) { 51 }
it_behaves_like 'invalid config', /must be less than or equal to 50/
end
context 'when it is not an integer' do
let(:config) { 1.5 }
it_behaves_like 'invalid config', /must be an integer/
end
context 'with empty hash config' do
let(:config) { {} }
it_behaves_like 'invalid config', /matrix builds config missing required keys: matrix/
end
end
context 'with numeric config' do
context 'when job is specified' do
let(:config) { 2 }
describe '#valid?' do
it { is_expected.to be_valid }
end
describe '#value' do
it 'returns job needs configuration' do
expect(parallel.value).to match(number: config)
end
end
end
end
context 'with matrix builds config' do
context 'when matrix is specified' do
let(:config) do
{
matrix: [
{ PROVIDER: 'aws', STACK: %w[monitoring app1 app2] },
{ PROVIDER: 'gcp', STACK: %w[data processing] }
]
}
end
describe '#valid?' do
it { is_expected.to be_valid }
end
describe '#value' do
it 'returns job needs configuration' do
expect(parallel.value).to match(matrix: [
{ PROVIDER: 'aws', STACK: %w[monitoring app1 app2] },
{ PROVIDER: 'gcp', STACK: %w[data processing] }
])
end
end
end
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
require_dependency 'active_model'
RSpec.describe Gitlab::Ci::Config::Entry::Product::Variables do
let(:entry) { described_class.new(config) }
describe 'validations' do
context 'when entry config value is correct' do
let(:config) do
{
'VARIABLE_1' => 1,
'VARIABLE_2' => 'value 2',
'VARIABLE_3' => :value_3,
:VARIABLE_4 => 'value 4',
5 => ['value 5'],
'VARIABLE_6' => ['value 6']
}
end
describe '#value' do
it 'returns hash with key value strings' do
expect(entry.value).to match({
'VARIABLE_1' => ['1'],
'VARIABLE_2' => ['value 2'],
'VARIABLE_3' => ['value_3'],
'VARIABLE_4' => ['value 4'],
'5' => ['value 5'],
'VARIABLE_6' => ['value 6']
})
end
end
describe '#errors' do
it 'does not append errors' do
expect(entry.errors).to be_empty
end
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
context 'when entry value is not correct' do
shared_examples 'invalid variables' do |message|
describe '#errors' do
it 'saves errors' do
expect(entry.errors).to include(message)
end
end
describe '#valid?' do
it 'is not valid' do
expect(entry).not_to be_valid
end
end
end
context 'with array' do
let(:config) { [:VAR, 'test'] }
it_behaves_like 'invalid variables', /should be a hash of key value pairs/
end
context 'with empty array' do
let(:config) { { VAR: 'test', VAR2: [] } }
it_behaves_like 'invalid variables', /should be a hash of key value pairs/
end
context 'with nested array' do
let(:config) { { VAR: 'test', VAR2: [1, [2]] } }
it_behaves_like 'invalid variables', /should be a hash of key value pairs/
end
context 'with only one variable' do
let(:config) { { VAR: 'test' } }
it_behaves_like 'invalid variables', /variables config requires at least 2 items/
end
end
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
RSpec.describe Gitlab::Ci::Config::Normalizer::Factory do
describe '#create' do
context 'when no strategy applies' do
subject(:subject) { described_class.new(nil, nil).create } # rubocop:disable Rails/SaveBang
it { is_expected.to be_empty }
end
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
RSpec.describe Gitlab::Ci::Config::Normalizer::MatrixStrategy do
describe '.applies_to?' do
subject { described_class.applies_to?(config) }
context 'with hash that has :matrix key' do
let(:config) { { matrix: [] } }
it { is_expected.to be_truthy }
end
context 'with hash that does not have :matrix key' do
let(:config) { { number: [] } }
it { is_expected.to be_falsey }
end
context 'with a number' do
let(:config) { 5 }
it { is_expected.to be_falsey }
end
end
describe '.build_from' do
subject { described_class.build_from('test', config) }
let(:config) do
{
matrix: [
{ 'PROVIDER' => %w[aws], 'STACK' => %w[app1 app2] },
{ 'PROVIDER' => %w[ovh gcp], 'STACK' => %w[app] }
]
}
end
it { expect(subject.size).to eq(4) }
it 'has attributes' do
expect(subject.map(&:attributes)).to match_array(
[
{
name: 'test 1/4',
instance: 1,
parallel: { total: 4 },
variables: {
'PROVIDER' => 'aws',
'STACK' => 'app1'
}
},
{
name: 'test 2/4',
instance: 2,
parallel: { total: 4 },
variables: {
'PROVIDER' => 'aws',
'STACK' => 'app2'
}
},
{
name: 'test 3/4',
instance: 3,
parallel: { total: 4 },
variables: {
'PROVIDER' => 'ovh',
'STACK' => 'app'
}
},
{
name: 'test 4/4',
instance: 4,
parallel: { total: 4 },
variables: {
'PROVIDER' => 'gcp',
'STACK' => 'app'
}
}
]
)
end
it 'has parallelized name' do
expect(subject.map(&:name)).to match_array(
['test 1/4', 'test 2/4', 'test 3/4', 'test 4/4']
)
end
it 'has details' do
expect(subject.map(&:name_with_details)).to match_array(
[
'test (PROVIDER=aws; STACK=app1)',
'test (PROVIDER=aws; STACK=app2)',
'test (PROVIDER=gcp; STACK=app)',
'test (PROVIDER=ovh; STACK=app)'
]
)
end
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
RSpec.describe Gitlab::Ci::Config::Normalizer::NumberStrategy do
describe '.applies_to?' do
subject { described_class.applies_to?(config) }
context 'with numbers' do
let(:config) { 5 }
it { is_expected.to be_truthy }
end
context 'with hash that has :number key' do
let(:config) { { number: 5 } }
it { is_expected.to be_truthy }
end
context 'with a float number' do
let(:config) { 5.5 }
it { is_expected.to be_falsey }
end
context 'with hash that does not have :number key' do
let(:config) { { matrix: 5 } }
it { is_expected.to be_falsey }
end
end
describe '.build_from' do
subject { described_class.build_from('test', config) }
shared_examples 'parallelized job' do
it { expect(subject.size).to eq(3) }
it 'has attributes' do
expect(subject.map(&:attributes)).to match_array(
[
{ name: 'test 1/3', instance: 1, parallel: { total: 3 } },
{ name: 'test 2/3', instance: 2, parallel: { total: 3 } },
{ name: 'test 3/3', instance: 3, parallel: { total: 3 } }
]
)
end
it 'has parallelized name' do
expect(subject.map(&:name)).to match_array(
['test 1/3', 'test 2/3', 'test 3/3'])
end
end
context 'with numbers' do
let(:config) { 3 }
it_behaves_like 'parallelized job'
end
context 'with hash that has :number key' do
let(:config) { { number: 3 } }
it_behaves_like 'parallelized job'
end
end
end
...@@ -4,66 +4,13 @@ require 'fast_spec_helper' ...@@ -4,66 +4,13 @@ require 'fast_spec_helper'
RSpec.describe Gitlab::Ci::Config::Normalizer do RSpec.describe Gitlab::Ci::Config::Normalizer do
let(:job_name) { :rspec } let(:job_name) { :rspec }
let(:job_config) { { script: 'rspec', parallel: 5, name: 'rspec' } } let(:job_config) { { script: 'rspec', parallel: parallel_config, name: 'rspec', variables: variables_config } }
let(:config) { { job_name => job_config } } 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 describe '.normalize_jobs' do
subject { described_class.new(config).normalize_jobs } subject { described_class.new(config).normalize_jobs }
it 'does not have original job' do shared_examples 'parallel dependencies' do
is_expected.not_to include(job_name)
end
it 'has parallelized jobs' do
is_expected.to include(*expanded_job_names.map(&:to_sym))
end
it 'sets job instance in options' do
expect(subject.values).to all(include(:instance))
end
it 'parallelizes jobs with original config' do
original_config = config[job_name].except(:name)
configs = subject.values.map { |config| config.except(:name, :instance) }
expect(configs).to all(eq(original_config))
end
context 'when the job is not parallelized' do
let(:job_config) { { script: 'rspec', name: 'rspec' } }
it 'returns the same hash' do
is_expected.to eq(config)
end
end
context 'when there is a job with a slash in it' do
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"
]
is_expected.to include(*job_names)
end
end
context 'for dependencies' do
context "when job has dependencies on parallelized jobs" do context "when job has dependencies on parallelized jobs" do
let(:config) do let(:config) do
{ {
...@@ -91,9 +38,7 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do ...@@ -91,9 +38,7 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
end end
it "parallelizes dependencies" 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][:dependencies]).to include(*expanded_job_names)
expect(subject[:final_job][:dependencies]).to include(*job_names)
end end
it "includes the regular job in dependencies" do it "includes the regular job in dependencies" do
...@@ -102,14 +47,14 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do ...@@ -102,14 +47,14 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
end end
end end
context 'for needs' do shared_examples 'parallel needs' do
let(:expanded_job_attributes) do let(:expanded_job_attributes) do
expanded_job_names.map do |job_name| expanded_job_names.map do |job_name|
{ name: job_name, extra: :key } { name: job_name, extra: :key }
end end
end end
context "when job has needs on parallelized jobs" do context 'when job has needs on parallelized jobs' do
let(:config) do let(:config) do
{ {
job_name => job_config, job_name => job_config,
...@@ -124,12 +69,12 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do ...@@ -124,12 +69,12 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
} }
end end
it "parallelizes needs" do it 'parallelizes needs' do
expect(subject.dig(:other_job, :needs, :job)).to eq(expanded_job_attributes) expect(subject.dig(:other_job, :needs, :job)).to eq(expanded_job_attributes)
end end
end end
context "when there are dependencies which are both parallelized and not" do context 'when there are dependencies which are both parallelized and not' do
let(:config) do let(:config) do
{ {
job_name => job_config, job_name => job_config,
...@@ -141,21 +86,157 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do ...@@ -141,21 +86,157 @@ RSpec.describe Gitlab::Ci::Config::Normalizer do
needs: { needs: {
job: [ job: [
{ name: job_name.to_s, extra: :key }, { name: job_name.to_s, extra: :key },
{ name: "other_job", extra: :key } { name: 'other_job', extra: :key }
] ]
} }
} }
} }
end end
it "parallelizes dependencies" do it 'parallelizes dependencies' do
expect(subject.dig(:final_job, :needs, :job)).to include(*expanded_job_attributes) expect(subject.dig(:final_job, :needs, :job)).to include(*expanded_job_attributes)
end end
it "includes the regular job in dependencies" do it 'includes the regular job in dependencies' do
expect(subject.dig(:final_job, :needs, :job)).to include(name: 'other_job', extra: :key) expect(subject.dig(:final_job, :needs, :job)).to include(name: 'other_job', extra: :key)
end end
end end
end end
context 'with parallel config as integer' do
let(:variables_config) { {} }
let(:parallel_config) { 5 }
let(:expanded_job_names) do
[
'rspec 1/5',
'rspec 2/5',
'rspec 3/5',
'rspec 4/5',
'rspec 5/5'
]
end
it 'does not have original job' do
is_expected.not_to include(job_name)
end
it 'has parallelized jobs' do
is_expected.to include(*expanded_job_names.map(&:to_sym))
end
it 'sets job instance in options' do
expect(subject.values).to all(include(:instance))
end
it 'parallelizes jobs with original config' do
original_config = config[job_name]
.except(:name)
.deep_merge(parallel: { total: parallel_config })
configs = subject.values.map { |config| config.except(:name, :instance) }
expect(configs).to all(eq(original_config))
end
context 'when the job is not parallelized' do
let(:job_config) { { script: 'rspec', name: 'rspec' } }
it 'returns the same hash' do
is_expected.to eq(config)
end
end
context 'when there is a job with a slash in it' do
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"
]
is_expected.to include(*job_names)
end
end
it_behaves_like 'parallel dependencies'
it_behaves_like 'parallel needs'
end
context 'with parallel matrix config' do
let(:variables_config) do
{
USER_VARIABLE: 'user value'
}
end
let(:parallel_config) do
{
matrix: [
{
VAR_1: [1],
VAR_2: [2, 3]
}
]
}
end
let(:expanded_job_names) do
[
'rspec 1/2',
'rspec 2/2'
]
end
it 'does not have original job' do
is_expected.not_to include(job_name)
end
it 'has parallelized jobs' do
is_expected.to include(*expanded_job_names.map(&:to_sym))
end
it 'sets job instance in options' do
expect(subject.values).to all(include(:instance))
end
it 'sets job variables', :aggregate_failures do
expect(subject.values[0]).to match(
a_hash_including(variables: { VAR_1: 1, VAR_2: 2, USER_VARIABLE: 'user value' })
)
expect(subject.values[1]).to match(
a_hash_including(variables: { VAR_1: 1, VAR_2: 3, USER_VARIABLE: 'user value' })
)
end
it 'parallelizes jobs with original config' do
configs = subject.values.map do |config|
config.except(:name, :instance, :variables)
end
original_config = config[job_name]
.except(:name, :variables)
.deep_merge(parallel: { total: 2 })
expect(configs).to all(match(a_hash_including(original_config)))
end
it_behaves_like 'parallel dependencies'
it_behaves_like 'parallel needs'
end
context 'when parallel config does not matches a factory' do
let(:variables_config) { {} }
let(:parallel_config) { }
it 'does not alter the job config' do
is_expected.to match(config)
end
end
end end
end end
...@@ -1269,27 +1269,104 @@ module Gitlab ...@@ -1269,27 +1269,104 @@ module Gitlab
end end
describe 'Parallel' do describe 'Parallel' do
let(:config) do
YAML.dump(rspec: { script: 'rspec',
parallel: parallel,
variables: { 'VAR1' => 1 } })
end
let(:config_processor) { Gitlab::Ci::YamlProcessor.new(config) }
let(:builds) { config_processor.stage_builds_attributes('test') }
context 'when job is parallelized' do context 'when job is parallelized' do
let(:parallel) { 5 } let(:parallel) { 5 }
let(:config) do
YAML.dump(rspec: { script: 'rspec',
parallel: parallel })
end
it 'returns parallelized jobs' do it 'returns parallelized jobs' do
config_processor = Gitlab::Ci::YamlProcessor.new(config)
builds = config_processor.stage_builds_attributes('test')
build_options = builds.map { |build| build[:options] } build_options = builds.map { |build| build[:options] }
expect(builds.size).to eq(5) expect(builds.size).to eq(5)
expect(build_options).to all(include(:instance, parallel: parallel)) expect(build_options).to all(include(:instance, parallel: { number: parallel, total: parallel }))
end end
it 'does not have the original job' do it 'does not have the original job' do
config_processor = Gitlab::Ci::YamlProcessor.new(config) expect(builds).not_to include(:rspec)
builds = config_processor.stage_builds_attributes('test') end
end
context 'with build matrix' do
let(:parallel) do
{
matrix: [
{ 'PROVIDER' => 'aws', 'STACK' => %w[monitoring app1 app2] },
{ 'PROVIDER' => 'ovh', 'STACK' => %w[monitoring backup app] },
{ 'PROVIDER' => 'gcp', 'STACK' => %w[data processing] }
]
}
end
it 'returns the number of parallelized jobs' do
expect(builds.size).to eq(8)
end
it 'returns the parallel config' do
build_options = builds.map { |build| build[:options] }
parallel_config = {
matrix: parallel[:matrix].map { |var| var.transform_values { |v| Array(v).flatten }},
total: build_options.size
}
expect(build_options).to all(include(:instance, parallel: parallel_config))
end
it 'sets matrix variables' do
build_variables = builds.map { |build| build[:yaml_variables] }
expected_variables = [
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'aws' },
{ key: 'STACK', value: 'monitoring' }
],
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'aws' },
{ key: 'STACK', value: 'app1' }
],
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'aws' },
{ key: 'STACK', value: 'app2' }
],
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'ovh' },
{ key: 'STACK', value: 'monitoring' }
],
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'ovh' },
{ key: 'STACK', value: 'backup' }
],
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'ovh' },
{ key: 'STACK', value: 'app' }
],
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'gcp' },
{ key: 'STACK', value: 'data' }
],
[
{ key: 'VAR1', value: '1' },
{ key: 'PROVIDER', value: 'gcp' },
{ key: 'STACK', value: 'processing' }
]
].map { |vars| vars.map { |var| a_hash_including(var) } }
expect(build_variables).to match(expected_variables)
end
it 'does not have the original job' do
expect(builds).not_to include(:rspec) expect(builds).not_to include(:rspec)
end end
end end
...@@ -2619,6 +2696,14 @@ module Gitlab ...@@ -2619,6 +2696,14 @@ module Gitlab
.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, .to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
'rspec: unknown keys in `extends` (something)') 'rspec: unknown keys in `extends` (something)')
end end
it 'returns errors if parallel is invalid' do
config = YAML.dump({ rspec: { parallel: 'test', script: 'test' } })
expect { Gitlab::Ci::YamlProcessor.new(config) }
.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError,
'jobs:rspec:parallel should be an integer or a hash')
end
end end
describe "#validation_message" do describe "#validation_message" do
......
...@@ -3007,25 +3007,46 @@ RSpec.describe Ci::Build do ...@@ -3007,25 +3007,46 @@ RSpec.describe Ci::Build do
end end
context 'when build is parallelized' do context 'when build is parallelized' do
let(:total) { 5 } shared_examples 'parallelized jobs config' do
let(:index) { 3 } let(:index) { 3 }
let(:total) { 5 }
before do before do
build.options[:parallel] = total build.options[:parallel] = config
build.options[:instance] = index build.options[:instance] = index
build.name = "#{build.name} #{index}/#{total}" end
it 'includes CI_NODE_INDEX' do
is_expected.to include(
{ key: 'CI_NODE_INDEX', value: index.to_s, public: true, masked: false }
)
end
it 'includes correct CI_NODE_TOTAL' do
is_expected.to include(
{ key: 'CI_NODE_TOTAL', value: total.to_s, public: true, masked: false }
)
end
end end
it 'includes CI_NODE_INDEX' do context 'when parallel is a number' do
is_expected.to include( let(:config) { 5 }
{ key: 'CI_NODE_INDEX', value: index.to_s, public: true, masked: false }
) it_behaves_like 'parallelized jobs config'
end end
it 'includes correct CI_NODE_TOTAL' do context 'when parallel is hash with the total key' do
is_expected.to include( let(:config) { { total: 5 } }
{ key: 'CI_NODE_TOTAL', value: total.to_s, public: true, masked: false }
) it_behaves_like 'parallelized jobs config'
end
context 'when parallel is nil' do
let(:config) {}
it_behaves_like 'parallelized jobs config' do
let(:total) { 1 }
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