diff --git a/config/feature_flags/gitlab_com_derisk/ci_fix_input_types.yml b/config/feature_flags/gitlab_com_derisk/ci_fix_input_types.yml new file mode 100644 index 0000000000000000000000000000000000000000..b910822c618d14ebce444dd8bf5e5f6815069874 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/ci_fix_input_types.yml @@ -0,0 +1,9 @@ +--- +name: ci_fix_input_types +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/434826 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/145257 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/443301 +milestone: '16.10' +group: group::pipeline authoring +type: gitlab_com_derisk +default_enabled: false diff --git a/lib/gitlab/ci/config/interpolation/block.rb b/lib/gitlab/ci/config/interpolation/block.rb index dc1ca423a81673c821532b5b6b42966551384174..0c9a83fed1d575e481f7237e50f6db6d50b7a5c0 100644 --- a/lib/gitlab/ci/config/interpolation/block.rb +++ b/lib/gitlab/ci/config/interpolation/block.rb @@ -17,7 +17,15 @@ class Block PATTERN = /(?<block>\$\[\[\s*(?<data>\S{1}.*?\S{1})\s*\]\])/ MAX_FUNCTIONS = 3 - attr_reader :block, :data, :ctx, :errors + attr_reader :data, :ctx, :errors + + def self.match(data) + return data unless data.is_a?(String) && data.include?(PREFIX) + + data.gsub(PATTERN) do + yield ::Regexp.last_match(1), ::Regexp.last_match(2) + end + end def initialize(block, data, ctx) @block = block @@ -43,16 +51,18 @@ def value @value end - def self.match(data) - return data unless data.is_a?(String) && data.include?(PREFIX) + def length + block.length + end - data.gsub(PATTERN) do - yield ::Regexp.last_match(1), ::Regexp.last_match(2) - end + def to_s + block end private + attr_reader :block + # We expect the block data to be a string with one or more entities delimited by pipes: # <access> | <function1> | <function2> | ... <functionN> def evaluate! diff --git a/lib/gitlab/ci/config/interpolation/template.rb b/lib/gitlab/ci/config/interpolation/template.rb index 2a0f98480024dce10dcea4dd7551bbffdebc06f3..697be852c81ba276ccc1aaedf577f5e3bd0a56a4 100644 --- a/lib/gitlab/ci/config/interpolation/template.rb +++ b/lib/gitlab/ci/config/interpolation/template.rb @@ -10,6 +10,8 @@ class Template attr_reader :blocks, :ctx MAX_BLOCKS = 10_000 + BLOCK_PATTERN = /(?<block>\$\[\[\s*(?<data>\S{1}.*?\S{1})\s*\]\])/ + BLOCK_PREFIX = '$[[' def initialize(config, ctx) @config = Interpolation::Config.fabricate(config) @@ -39,20 +41,73 @@ def interpolated private def interpolate! - @result = @config.replace! do |data| + @result = @config.replace! do |node| break if @errors.any? - Interpolation::Block.match(data) do |block, data| - block = (@blocks[block] ||= Interpolation::Block.new(block, data, ctx)) - - break @errors.push('too many interpolation blocks') if @blocks.size > MAX_BLOCKS - break unless block.valid? - - block.value + if Feature.enabled?(:ci_fix_input_types, Feature.current_request, type: :gitlab_com_derisk) + interpolate_with_fixed_types!(node) + else + legacy_interpolate!(node) end end end strong_memoize_attr :interpolate! + + def interpolate_with_fixed_types!(node) + return node unless node_might_contain_interpolation_block?(node) + + matches = node.scan(BLOCK_PATTERN) + return node if matches.empty? + + blocks = interpolate_blocks(matches) + return unless @errors.none? && blocks.present? + + get_interpolated_node_content!(node, blocks) + end + + def legacy_interpolate!(node) + Interpolation::Block.match(node) do |block, data| + block = (@blocks[block] ||= Interpolation::Block.new(block, data, ctx)) + + break @errors.push('too many interpolation blocks') if @blocks.size > MAX_BLOCKS + break unless block.valid? + + block.value + end + end + + def node_might_contain_interpolation_block?(node) + node.is_a?(String) && node.include?(BLOCK_PREFIX) + end + + def interpolate_blocks(matches) + matches.map do |match, data| + block = (@blocks[match] ||= Interpolation::Block.new(match, data, ctx)) + + break @errors.push('too many interpolation blocks') if @blocks.size > MAX_BLOCKS + break unless block.valid? + + block + end + end + + def get_interpolated_node_content!(node, blocks) + if used_inside_a_string?(node, blocks) + interpolate_string_node!(node, blocks) + else + blocks.first.value + end + end + + def interpolate_string_node!(node, blocks) + blocks.reduce(node) do |interpolated_node, block| + interpolated_node.gsub(block.to_s, block.value.to_s) + end + end + + def used_inside_a_string?(node, blocks) + blocks.count > 1 || node.length != blocks.first.length + end end end end diff --git a/spec/lib/gitlab/ci/config/interpolation/block_spec.rb b/spec/lib/gitlab/ci/config/interpolation/block_spec.rb index 7b9111f11d373b52bf60cbe41bf07ec090fa37ad..02bbbcb5a17acac443b08f95460be6169ffbd90b 100644 --- a/spec/lib/gitlab/ci/config/interpolation/block_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/block_spec.rb @@ -109,4 +109,16 @@ end end end + + describe '#to_s' do + it 'returns the interpolation block' do + expect(subject.to_s).to eq(block) + end + end + + describe '#length' do + it 'returns the length of the interpolation block' do + expect(subject.length).to eq(block.length) + end + end end diff --git a/spec/lib/gitlab/ci/config/interpolation/template_spec.rb b/spec/lib/gitlab/ci/config/interpolation/template_spec.rb index c7d8882255848a43cb2c4a5d16d74115e885759f..647c698edb7979481841218c9f96840be4be90b7 100644 --- a/spec/lib/gitlab/ci/config/interpolation/template_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/template_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Interpolation::Template, feature_category: :pipeline_composition do subject { described_class.new(YAML.safe_load(config), ctx) } @@ -13,16 +13,18 @@ $[[ inputs.key ]]: name: $[[ inputs.key ]] - script: my-value + parallel: $[[ inputs.parallel ]] + allow_failure: $[[ inputs.allow_failure ]] + script: 'echo "This job makes $[[ inputs.parallel ]] jobs for the $[[ inputs.env ]] env"' CFG end let(:ctx) do - { inputs: { env: 'dev', key: 'abc' } } + { inputs: { allow_failure: true, env: 'dev', key: 'abc', parallel: 6 } } end it 'collects interpolation blocks' do - expect(subject.size).to eq 2 + expect(subject.size).to eq 4 end it 'interpolates the values properly' do @@ -33,10 +35,32 @@ abc: name: abc - script: my-value + parallel: 6 + allow_failure: true + script: 'echo "This job makes 6 jobs for the dev env"' RESULT end + context 'when ci_fix_input_types is disabled' do + before do + stub_feature_flags(ci_fix_input_types: false) + end + + it 'interpolates all values as strings' do + expect(subject.interpolated).to eq YAML.safe_load <<~RESULT + test: + spec: + env: dev + + abc: + name: abc + parallel: '6' + allow_failure: 'true' + script: 'echo "This job makes 6 jobs for the dev env"' + RESULT + end + end + context 'when interpolation can not be performed' do let(:config) { '$[[ xxx.yyy ]]: abc' } diff --git a/spec/lib/gitlab/ci/config/yaml/loader_spec.rb b/spec/lib/gitlab/ci/config/yaml/loader_spec.rb index f8563533b04c13c03e159d7b8d51e5ba28877b34..2b1b85baeaead4b8273d72615a0ff45ee99927c5 100644 --- a/spec/lib/gitlab/ci/config/yaml/loader_spec.rb +++ b/spec/lib/gitlab/ci/config/yaml/loader_spec.rb @@ -16,7 +16,7 @@ 'echo "Building with clang and optimization level 3"', 'echo "1.0.0"' ], - parallel: '2' + parallel: 2 }, 'my-job-test': { stage: 'build', @@ -24,7 +24,7 @@ 'echo "Testing with pytest"', 'if [ true == true ]; then echo "Coverage is enabled"; fi' ], - allow_failure: 'false' + allow_failure: false }, 'my-job-deploy': { stage: 'build',