Commit 6de09370 authored by James Fargher's avatar James Fargher

Add file matching rule to flexible CI rules

This allows support for rules based on files in the repository:

  job:
    script:
    - echo Dockerfile exists
    rules:
    - exists:
      - Dockerfile
parent c865089c
---
title: Add file matching rule to flexible CI rules
merge_request: 16574
author:
type: added
# frozen_string_literal: true
module Gitlab
module Ci
module Build
class Rules::Rule::Clause::Exists < Rules::Rule::Clause
# The maximum number of patterned glob comparisons that will be
# performed before the rule assumes that it has a match
MAX_PATTERN_COMPARISONS = 10_000
def initialize(globs)
globs = Array(globs)
@top_level_only = globs.all? { |glob| top_level_glob?(glob) }
@exact_globs, @pattern_globs = globs.partition { |glob| exact_glob?(glob) }
end
def satisfied_by?(pipeline, seed)
paths = worktree_paths(pipeline)
exact_matches?(paths) || pattern_matches?(paths)
end
private
def worktree_paths(pipeline)
if @top_level_only
pipeline.project.repository.tree(pipeline.sha).blobs.map(&:path)
else
pipeline.project.repository.ls_files(pipeline.sha)
end
end
def exact_matches?(paths)
@exact_globs.any? { |glob| paths.bsearch { |path| glob <=> path } }
end
def pattern_matches?(paths)
comparisons = 0
@pattern_globs.any? do |glob|
paths.any? do |path|
comparisons += 1
comparisons > MAX_PATTERN_COMPARISONS || pattern_match?(glob, path)
end
end
end
def pattern_match?(glob, path)
File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB)
end
# matches glob patterns that only match files in the top level directory
def top_level_glob?(glob)
!glob.include?('/') && !glob.include?('**')
end
# matches glob patterns that have no metacharacters for File#fnmatch?
def exact_glob?(glob)
!glob.include?('*') && !glob.include?('?') && !glob.include?('[') && !glob.include?('{')
end
end
end
end
end
......@@ -8,11 +8,11 @@ module Gitlab
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
CLAUSES = %i[if changes].freeze
ALLOWED_KEYS = %i[if changes when start_in].freeze
CLAUSES = %i[if changes exists].freeze
ALLOWED_KEYS = %i[if changes exists when start_in].freeze
ALLOWED_WHEN = %w[on_success on_failure always never manual delayed].freeze
attributes :if, :changes, :when, :start_in
attributes :if, :changes, :exists, :when, :start_in
validations do
validates :config, presence: true
......@@ -24,7 +24,7 @@ module Gitlab
with_options allow_nil: true do
validates :if, expression: true
validates :changes, array_of_strings: true
validates :changes, :exists, array_of_strings: true, length: { maximum: 50 }
validates :when, allowed_values: { in: ALLOWED_WHEN }
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists do
describe 'satisfied_by?' do
using RSpec::Parameterized::TableSyntax
where(:case_name, :globs, :files, :satisfied) do
'exact top-level match' | ['Dockerfile'] | { 'Dockerfile' => '', 'Gemfile' => '' } | true
'exact top-level no match' | ['Dockerfile'] | { 'Gemfile' => '' } | false
'pattern top-level match' | ['Docker*'] | { 'Dockerfile' => '', 'Gemfile' => '' } | true
'pattern top-level no match' | ['Docker*'] | { 'Gemfile' => '' } | false
'exact nested match' | ['project/build.properties'] | { 'project/build.properties' => '' } | true
'exact nested no match' | ['project/build.properties'] | { 'project/README.md' => '' } | false
'pattern nested match' | ['src/**/*.go'] | { 'src/gitlab.com/goproject/goproject.go' => '' } | true
'pattern nested no match' | ['src/**/*.go'] | { 'src/gitlab.com/goproject/README.md' => '' } | false
end
with_them do
let(:project) { create(:project, :custom_repo, files: files) }
let(:pipeline) { build(:ci_pipeline, project: project, sha: project.repository.head_commit.sha) }
subject { described_class.new(globs) }
it 'checks if any files exist' do
expect(subject.satisfied_by?(pipeline, nil)).to eq(satisfied)
end
end
end
end
......@@ -103,6 +103,52 @@ describe Gitlab::Ci::Config::Entry::Rules::Rule do
end
end
context 'when using a long list as an invalid changes: clause' do
let(:config) { { changes: ['app/'] * 51 } }
it { is_expected.not_to be_valid }
it 'returns errors' do
expect(subject.errors).to include(/changes is too long \(maximum is 50 characters\)/)
end
end
context 'when using a exists: clause' do
let(:config) { { exists: %w[app/ lib/ spec/ other/* paths/**/*.rb] } }
it { is_expected.to be_valid }
end
context 'when using a string as an invalid exists: clause' do
let(:config) { { exists: 'a regular string' } }
it { is_expected.not_to be_valid }
it 'reports an error about invalid policy' do
expect(subject.errors).to include(/should be an array of strings/)
end
end
context 'when using a list as an invalid exists: clause' do
let(:config) { { exists: [1, 2] } }
it { is_expected.not_to be_valid }
it 'returns errors' do
expect(subject.errors).to include(/exists should be an array of strings/)
end
end
context 'when using a long list as an invalid exists: clause' do
let(:config) { { exists: ['app/'] * 51 } }
it { is_expected.not_to be_valid }
it 'returns errors' do
expect(subject.errors).to include(/exists is too long \(maximum is 50 characters\)/)
end
end
context 'specifying a delayed job' do
let(:config) { { if: '$THIS || $THAT', when: 'delayed', start_in: '15 minutes' } }
......@@ -198,6 +244,12 @@ describe Gitlab::Ci::Config::Entry::Rules::Rule do
expect(entry.value).to eq(config)
end
end
context 'when using a exists: clause' do
let(:config) { { exists: %w[app/ lib/ spec/ other/* paths/**/*.rb] } }
it { is_expected.to eq(config) }
end
end
describe '.default' do
......
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