# frozen_string_literal: true # This is the CI Linter component that runs the syntax validations # while parsing the YAML config into a data structure that is # then presented to the caller as result object. # After syntax validations (done by Ci::Config), this component also # runs logical validation on the built data structure. module Gitlab module Ci class YamlProcessor ValidationError = Class.new(StandardError) def initialize(config_content, opts = {}) @config_content = config_content @opts = opts end def execute Gitlab::Ci::YamlProcessor::FeatureFlags.with_actor(project) do parse_config end end private def parse_config if @config_content.blank? return Result.new(errors: ['Please provide content of .gitlab-ci.yml']) end @ci_config = Gitlab::Ci::Config.new(@config_content, **@opts) unless @ci_config.valid? return Result.new(ci_config: @ci_config, errors: @ci_config.errors, warnings: @ci_config.warnings) end run_logical_validations! Result.new(ci_config: @ci_config, warnings: @ci_config&.warnings) rescue Gitlab::Ci::Config::ConfigError => e Result.new(ci_config: @ci_config, errors: [e.message], warnings: @ci_config&.warnings) rescue ValidationError => e Result.new(ci_config: @ci_config, errors: [e.message], warnings: @ci_config&.warnings) end def project @opts[:project] end def run_logical_validations! @stages = @ci_config.stages @jobs = @ci_config.normalized_jobs @jobs.each do |name, job| validate_job!(name, job) end check_circular_dependencies end def validate_job!(name, job) validate_job_stage!(name, job) validate_job_dependencies!(name, job) validate_job_needs!(name, job) validate_dynamic_child_pipeline_dependencies!(name, job) validate_job_environment!(name, job) end def validate_job_stage!(name, job) return unless job[:stage] unless job[:stage].is_a?(String) && job[:stage].in?(@stages) error!("#{name} job: chosen stage does not exist; available stages are #{@stages.join(", ")}") end end def validate_job_dependencies!(name, job) return unless job[:dependencies] job[:dependencies].each do |dependency| validate_job_dependency!(name, dependency) end end def validate_dynamic_child_pipeline_dependencies!(name, job) return unless includes = job.dig(:trigger, :include) Array(includes).each do |included| next unless included.is_a?(Hash) next unless dependency = included[:job] validate_job_dependency!(name, dependency) end end def validate_job_needs!(name, job) validate_needs_specification!(name, job.dig(:needs, :job)) job[:rules]&.each do |rule| validate_needs_specification!(name, rule.dig(:needs, :job)) end end def validate_needs_specification!(name, needs) return unless needs needs.each do |need| validate_job_dependency!(name, need[:name], 'need', optional: need[:optional]) end duplicated_needs = needs .group_by { |need| need[:name] } .select { |_, items| items.count > 1 } .keys unless duplicated_needs.empty? error!("#{name} has the following needs duplicated: #{duplicated_needs.join(', ')}.") end end def validate_job_dependency!(name, dependency, dependency_type = 'dependency', optional: false) unless @jobs[dependency.to_sym] # Here, we ignore the optional needed job if it is not in the result YAML due to the `include` # rules. In `lib/gitlab/ci/pipeline/seed/build.rb`, we use `optional` again to ignore the # optional needed job in case it is excluded from the pipeline due to the job's rules. return if optional error!("#{name} job: undefined #{dependency_type}: #{dependency}") end job_stage_index = stage_index(name) dependency_stage_index = stage_index(dependency) unless dependency_stage_index.present? && dependency_stage_index <= job_stage_index error!("#{name} job: #{dependency_type} #{dependency} is not defined in current or prior stages") end end def stage_index(name) stage = @jobs.dig(name.to_sym, :stage) @stages.index(stage) end def validate_job_environment!(name, job) return unless job[:environment] return unless job[:environment].is_a?(Hash) environment = job[:environment] validate_on_stop_job!(name, environment, environment[:on_stop]) end def validate_on_stop_job!(name, environment, on_stop) return unless on_stop on_stop_job = @jobs[on_stop.to_sym] unless on_stop_job error!("#{name} job: on_stop job #{on_stop} is not defined") end unless on_stop_job[:environment] error!("#{name} job: on_stop job #{on_stop} does not have environment defined") end unless on_stop_job[:environment][:name] == environment[:name] error!("#{name} job: on_stop job #{on_stop} have different environment name") end unless on_stop_job[:environment][:action] == 'stop' error!("#{name} job: on_stop job #{on_stop} needs to have action stop defined") end end def check_circular_dependencies jobs = @jobs.values.to_h do |job| name = job[:name].to_s needs = job.dig(:needs, :job).to_a [name, needs.map { |need| need[:name].to_s }] end Dag.check_circular_dependencies!(jobs) end def error!(message) raise ValidationError, message end end end end