diff --git a/lib/gitlab/ci/config/header/inputs.rb b/lib/gitlab/ci/config/header/inputs.rb new file mode 100644 index 0000000000000000000000000000000000000000..9d08b9ec15966a1e8418eb3c00effa390d01b3fb --- /dev/null +++ b/lib/gitlab/ci/config/header/inputs.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Header + class Inputs < ::Gitlab::Config::Entry::ComposableHash + def compose!(deps = nil) + super + + validate_rules! if @entries + end + + private + + def composable_class(_name, _config) + Header::Input + end + + def validate_rules! + Inputs::Validator.new(@entries).validate! + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/header/inputs/validator.rb b/lib/gitlab/ci/config/header/inputs/validator.rb new file mode 100644 index 0000000000000000000000000000000000000000..56774522a8289dd0b8d7772e0705082bfd853a44 --- /dev/null +++ b/lib/gitlab/ci/config/header/inputs/validator.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Header + class Inputs + class Validator + def initialize(entries) + @entries = entries + end + + def validate! + validate_undefined_input_references + validate_circular_dependencies + end + + private + + attr_reader :entries + + def validate_undefined_input_references + defined_inputs = entries.keys.map(&:to_s) + + entries.each do |name, input| + next unless input.input_rules + + input.input_rules.each_with_index do |rule, idx| + validate_rule_references(name, rule, idx, defined_inputs) + end + end + end + + def validate_rule_references(input_name, rule, rule_index, defined_inputs) + return unless rule[:if] + + referenced_inputs = extract_input_names_from(rule[:if]) + return unless referenced_inputs + + undefined_inputs = referenced_inputs - defined_inputs + return if undefined_inputs.empty? + + entries[input_name].validator.errors.add( + :config, + "rule[#{rule_index}] references undefined inputs: #{undefined_inputs.join(', ')}" + ) + end + + def validate_circular_dependencies + dependency_graph = build_dependency_graph + + dependency_graph.each_key do |input_name| + next unless has_cycle?(input_name, dependency_graph, Set.new, Set.new) + + entries[input_name].validator.errors.add(:config, "circular dependency detected") + end + end + + def build_dependency_graph + graph = {} + + entries.each do |name, input| + graph[name] = get_input_dependencies(input).map(&:to_sym) + end + + graph + end + + def get_input_dependencies(input) + return [] unless input.input_rules + + input.input_rules.flat_map do |rule| + next [] unless rule[:if] + + extract_input_names_from(rule[:if]) || [] + end + end + + def has_cycle?(node, graph, visited, stack) + return true if stack.include?(node) + return false if visited.include?(node) + + visited.add(node) + stack.add(node) + + (graph[node] || []).each do |neighbor| + return true if has_cycle?(neighbor, graph, visited, stack) + end + + stack.delete(node) + false + end + + def extract_input_names_from(if_clause) + statement = Gitlab::Ci::Pipeline::Expression::Statement.new(if_clause) + statement.input_names + rescue Gitlab::Ci::Pipeline::Expression::Statement::StatementError + # Invalid expressions are caught by Rule entry validation + nil + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/header/inputs/validator_spec.rb b/spec/lib/gitlab/ci/config/header/inputs/validator_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..606aea8433827c76e51b0882ace36d236cb93a05 --- /dev/null +++ b/spec/lib/gitlab/ci/config/header/inputs/validator_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Header::Inputs::Validator, feature_category: :pipeline_composition do + let(:factory) do + Gitlab::Config::Entry::Factory.new(Gitlab::Ci::Config::Header::Inputs) + .value(inputs_hash) + .with(key: :inputs) + end + + let(:inputs) { factory.create! } + let(:entries) { inputs.instance_variable_get(:@entries) } + + subject(:validator) { described_class.new(entries) } + + describe '#validate!' do + before do + inputs.compose! + end + + context 'when inputs are valid' do + let(:inputs_hash) do + { + environment: { + options: %w[development staging production] + }, + region: { + options: %w[us eu asia] + } + } + end + + it 'does not add errors' do + validator.validate! + expect(inputs).to be_valid + end + end + + context 'when rules reference undefined inputs' do + let(:inputs_hash) do + { + environment: { + options: %w[development production] + }, + resource_tier: { + rules: [ + { + if: '$[[ inputs.undefined_input ]] == "value"', + options: %w[small medium] + } + ] + } + } + end + + it 'adds an error for undefined input reference' do + validator.validate! + expect(inputs).not_to be_valid + expect(inputs.errors).to include(match(/rule\[0\] references undefined inputs: undefined_input/)) + end + end + + context 'when rules reference multiple undefined inputs' do + let(:inputs_hash) do + { + environment: { + options: %w[development production] + }, + resource_tier: { + rules: [ + { + if: '$[[ inputs.undefined_one ]] == "value" && $[[ inputs.undefined_two ]] == "other"', + options: %w[small medium] + } + ] + } + } + end + + it 'adds an error listing all undefined inputs' do + validator.validate! + expect(inputs).not_to be_valid + expect(inputs.errors).to include(match(/rule\[0\] references undefined inputs: undefined_one, undefined_two/)) + end + end + + context 'when multiple rules have undefined inputs' do + let(:inputs_hash) do + { + environment: { + options: %w[development production] + }, + resource_tier: { + rules: [ + { + if: '$[[ inputs.undefined_one ]] == "value"', + options: %w[small] + }, + { + if: '$[[ inputs.undefined_two ]] == "value"', + options: %w[medium] + } + ] + } + } + end + + it 'adds errors for each rule' do + validator.validate! + expect(inputs).not_to be_valid + expect(inputs.errors).to include( + match(/rule\[0\] references undefined inputs: undefined_one/), + match(/rule\[1\] references undefined inputs: undefined_two/) + ) + end + end + + context 'when rules reference CI variables' do + let(:inputs_hash) do + { + environment: { + options: %w[development production] + }, + region: { + rules: [ + { + if: '$[[ inputs.environment ]] == "production" && $CI_COMMIT_BRANCH == "main"', + options: %w[us eu] + } + ] + } + } + end + + it 'allows CI variable references' do + validator.validate! + expect(inputs).to be_valid + end + end + + context 'when expression syntax is invalid' do + let(:inputs_hash) do + { + region: { + rules: [{ if: '&&&&', options: %w[us] }] + } + } + end + + it 'does not fail validation for invalid expressions' do + validator.validate! + expect(inputs).to be_valid + end + end + + context 'when rule has no if clause' do + let(:inputs_hash) do + { + region: { + rules: [{ options: %w[local] }] + } + } + end + + it 'does not fail validation for fallback rules' do + validator.validate! + expect(inputs).to be_valid + end + end + + context 'when there is a circular dependency' do + let(:inputs_hash) do + { + region: { + rules: [{ if: '$[[ inputs.size ]] == "large"', options: %w[us] }] + }, + size: { + rules: [{ if: '$[[ inputs.region ]] == "us"', options: %w[large] }] + } + } + end + + it 'detects and reports circular dependency' do + validator.validate! + expect(inputs).not_to be_valid + expect(inputs.errors).to include(match(/circular dependency detected/)) + end + end + + context 'when there is a self-referencing input' do + let(:inputs_hash) do + { + region: { + rules: [{ if: '$[[ inputs.region ]] == "us"', options: %w[us eu] }] + } + } + end + + it 'detects self-reference as circular dependency' do + validator.validate! + expect(inputs).not_to be_valid + expect(inputs.errors).to include(match(/circular dependency detected/)) + end + end + + context 'when there is a valid dependency chain' do + let(:inputs_hash) do + { + environment: { options: %w[dev staging prod] }, + region: { + rules: [{ if: '$[[ inputs.environment ]] == "prod"', options: %w[us eu] }] + }, + size: { + rules: [{ if: '$[[ inputs.region ]] == "us"', options: %w[large xlarge] }] + } + } + end + + it 'allows valid dependency chains' do + validator.validate! + expect(inputs).to be_valid + end + end + + context 'when combining undefined inputs and circular dependencies' do + let(:inputs_hash) do + { + input_a: { + rules: [{ if: '$[[ inputs.input_b ]] == "value"', options: %w[a] }] + }, + input_b: { + rules: [{ if: '$[[ inputs.input_a ]] == "value" && $[[ inputs.undefined ]] == "x"', options: %w[b] }] + } + } + end + + it 'reports both types of errors' do + validator.validate! + expect(inputs).not_to be_valid + expect(inputs.errors).to include( + match(/references undefined inputs/), + match(/circular dependency detected/) + ) + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/header/inputs_spec.rb b/spec/lib/gitlab/ci/config/header/inputs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d821e706958e28478f62e3a323a72d99069a1f11 --- /dev/null +++ b/spec/lib/gitlab/ci/config/header/inputs_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Config::Header::Inputs, feature_category: :pipeline_composition do + let(:factory) do + Gitlab::Config::Entry::Factory.new(described_class) + .value(inputs_hash) + .with(key: :inputs) + end + + subject(:inputs) { factory.create! } + + describe 'integration with Validator' do + before do + inputs.compose! + end + + context 'with valid inputs' do + let(:inputs_hash) do + { + environment: { + options: %w[development staging production] + }, + region: { + options: %w[us eu asia] + } + } + end + + it { is_expected.to be_valid } + end + + context 'when Validator detects undefined inputs' do + let(:inputs_hash) do + { + environment: { + options: %w[development production] + }, + resource_tier: { + rules: [ + { + if: '$[[ inputs.undefined_input ]] == "value"', + options: %w[small medium] + } + ] + } + } + end + + it 'propagates validation errors from Validator' do + expect(inputs).not_to be_valid + expect(inputs.errors.first).to include('rule[0] references undefined inputs: undefined_input') + end + end + + context 'when Validator detects circular dependency' do + let(:inputs_hash) do + { + region: { + rules: [{ if: '$[[ inputs.size ]] == "large"', options: %w[us] }] + }, + size: { + rules: [{ if: '$[[ inputs.region ]] == "us"', options: %w[large] }] + } + } + end + + it 'propagates validation errors from Validator' do + expect(inputs).not_to be_valid + expect(inputs.errors).to include(match(/circular dependency detected/)) + end + end + end +end