Commit c73f2fd4 authored by Max Woolf's avatar Max Woolf

Add exists support to includes:rules CI config

Adds support for includes:rules:exists in CI config
to conditionally include files based on the existance
of another file.

Changelog: added
parent ed3b8e59
...@@ -269,10 +269,11 @@ see this [CI/CD variable demo](https://youtu.be/4XR8gw3Pkos). ...@@ -269,10 +269,11 @@ see this [CI/CD variable demo](https://youtu.be/4XR8gw3Pkos).
> - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/337507) GitLab 14.3. > - [Enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/337507) GitLab 14.3.
> - [Feature flag `ci_include_rules` removed](https://gitlab.com/gitlab-org/gitlab/-/issues/337507) in GitLab 14.4. > - [Feature flag `ci_include_rules` removed](https://gitlab.com/gitlab-org/gitlab/-/issues/337507) in GitLab 14.4.
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/337507) in GitLab 14.4. > - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/337507) in GitLab 14.4.
> - [Support for `exists` keyword added](https://gitlab.com/gitlab-org/gitlab/-/issues/341511) in GitLab 14.5.
You can use [`rules`](index.md#rules) with `include` to conditionally include other configuration files. You can use [`rules`](index.md#rules) with `include` to conditionally include other configuration files.
You can only use [`if` rules](index.md#rulesif) in `include`, and only with [certain variables](#use-variables-with-include). You can only use [`if` rules](index.md#rulesif) and [`exists` rules](index.md#rulesexists) in `include`, and only with
`rules` keywords such as `changes` and `exists` are not supported. [certain variables](#use-variables-with-include). `rules` keyword `changes` is not supported.
```yaml ```yaml
include: include:
......
...@@ -177,7 +177,9 @@ audit trail: ...@@ -177,7 +177,9 @@ audit trail:
include: # Execute individual project's configuration (if project contains .gitlab-ci.yml) include: # Execute individual project's configuration (if project contains .gitlab-ci.yml)
project: '$CI_PROJECT_PATH' project: '$CI_PROJECT_PATH'
file: '$CI_CONFIG_PATH' file: '$CI_CONFIG_PATH'
ref: '$CI_COMMIT_REF_NAME' # Must be defined or MR pipelines always use the use default branch. ref: '$CI_COMMIT_REF_NAME' # Must be defined or MR pipelines always use the use default branch
rules:
- exists: '$CI_CONFIG_PATH'
``` ```
##### Ensure compliance jobs are always run ##### Ensure compliance jobs are always run
......
...@@ -5,6 +5,8 @@ module Gitlab ...@@ -5,6 +5,8 @@ module Gitlab
module Build module Build
module Context module Context
class Base class Base
include Gitlab::Utils::StrongMemoize
attr_reader :pipeline attr_reader :pipeline
def initialize(pipeline) def initialize(pipeline)
...@@ -15,6 +17,26 @@ module Gitlab ...@@ -15,6 +17,26 @@ module Gitlab
raise NotImplementedError raise NotImplementedError
end end
def project
pipeline.project
end
def sha
pipeline.sha
end
def top_level_worktree_paths
strong_memoize(:top_level_worktree_paths) do
project.repository.tree(sha).blobs.map(&:path)
end
end
def all_worktree_paths
strong_memoize(:all_worktree_paths) do
project.repository.ls_files(sha)
end
end
protected protected
def pipeline_attributes def pipeline_attributes
......
...@@ -15,19 +15,21 @@ module Gitlab ...@@ -15,19 +15,21 @@ module Gitlab
@exact_globs, @pattern_globs = globs.partition(&method(:exact_glob?)) @exact_globs, @pattern_globs = globs.partition(&method(:exact_glob?))
end end
def satisfied_by?(pipeline, context) def satisfied_by?(_pipeline, context)
paths = worktree_paths(pipeline) paths = worktree_paths(context)
exact_matches?(paths) || pattern_matches?(paths) exact_matches?(paths) || pattern_matches?(paths)
end end
private private
def worktree_paths(pipeline) def worktree_paths(context)
return unless context.project
if @top_level_only if @top_level_only
pipeline.top_level_worktree_paths context.top_level_worktree_paths
else else
pipeline.all_worktree_paths context.all_worktree_paths
end end
end end
......
...@@ -9,9 +9,9 @@ module Gitlab ...@@ -9,9 +9,9 @@ module Gitlab
include ::Gitlab::Config::Entry::Validatable include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[if].freeze ALLOWED_KEYS = %i[if exists].freeze
attributes :if attributes :if, :exists
validations do validations do
validates :config, presence: true validates :config, presence: true
......
...@@ -5,6 +5,8 @@ module Gitlab ...@@ -5,6 +5,8 @@ module Gitlab
class Config class Config
module External module External
class Context class Context
include Gitlab::Utils::StrongMemoize
TimeoutError = Class.new(StandardError) TimeoutError = Class.new(StandardError)
attr_reader :project, :sha, :user, :parent_pipeline, :variables attr_reader :project, :sha, :user, :parent_pipeline, :variables
...@@ -22,6 +24,18 @@ module Gitlab ...@@ -22,6 +24,18 @@ module Gitlab
yield self if block_given? yield self if block_given?
end end
def top_level_worktree_paths
strong_memoize(:top_level_worktree_paths) do
project.repository.tree(sha).blobs.map(&:path)
end
end
def all_worktree_paths
strong_memoize(:all_worktree_paths) do
project.repository.ls_files(sha)
end
end
def mutate(attrs = {}) def mutate(attrs = {})
self.class.new(**attrs) do |ctx| self.class.new(**attrs) do |ctx|
ctx.expandset = expandset ctx.expandset = expandset
......
...@@ -3,10 +3,8 @@ ...@@ -3,10 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists do RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists do
describe '#satisfied_by?' do shared_examples 'an exists rule with a context' do
let(:pipeline) { build(:ci_pipeline, project: project, sha: project.repository.head_commit.sha) } subject { described_class.new(globs).satisfied_by?(pipeline, context) }
subject { described_class.new(globs).satisfied_by?(pipeline, nil) }
it_behaves_like 'a glob matching rule' do it_behaves_like 'a glob matching rule' do
let(:project) { create(:project, :custom_repo, files: files) } let(:project) { create(:project, :custom_repo, files: files) }
...@@ -24,4 +22,26 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists do ...@@ -24,4 +22,26 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Exists do
it { is_expected.to be_truthy } it { is_expected.to be_truthy }
end end
end end
describe '#satisfied_by?' do
let(:pipeline) { build(:ci_pipeline, project: project, sha: project.repository.head_commit.sha) }
context 'when context is Build::Context::Build' do
it_behaves_like 'an exists rule with a context' do
let(:context) { Gitlab::Ci::Build::Context::Build.new(pipeline, sha: 'abc1234') }
end
end
context 'when context is Build::Context::Global' do
it_behaves_like 'an exists rule with a context' do
let(:context) { Gitlab::Ci::Build::Context::Global.new(pipeline, yaml_variables: {}) }
end
end
context 'when context is Config::External::Context' do
it_behaves_like 'an exists rule with a context' do
let(:context) { Gitlab::Ci::Config::External::Context.new(project: project, sha: project.repository.tree.sha) }
end
end
end
end end
...@@ -5,7 +5,7 @@ require 'fast_spec_helper' ...@@ -5,7 +5,7 @@ require 'fast_spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do
let(:factory) do let(:factory) do
Gitlab::Config::Entry::Factory.new(described_class) Gitlab::Config::Entry::Factory.new(described_class)
.value(config) .value(config)
end end
subject(:entry) { factory.create! } subject(:entry) { factory.create! }
...@@ -25,6 +25,12 @@ RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do ...@@ -25,6 +25,12 @@ RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do
it { is_expected.to be_valid } it { is_expected.to be_valid }
end end
context 'when specifying an exists: clause' do
let(:config) { { exists: './this.md' } }
it { is_expected.to be_valid }
end
context 'using a list of multiple expressions' do context 'using a list of multiple expressions' do
let(:config) { { if: ['$MY_VAR == "this"', '$YOUR_VAR == "that"'] } } let(:config) { { if: ['$MY_VAR == "this"', '$YOUR_VAR == "that"'] } }
...@@ -86,5 +92,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do ...@@ -86,5 +92,13 @@ RSpec.describe Gitlab::Ci::Config::Entry::Include::Rules::Rule do
expect(subject).to eq(if: '$THIS || $THAT') expect(subject).to eq(if: '$THIS || $THAT')
end end
end end
context 'when specifying an exists: clause' do
let(:config) { { exists: './test.md' } }
it 'returns the config' do
expect(subject).to eq(exists: './test.md')
end
end
end end
end end
...@@ -406,7 +406,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do ...@@ -406,7 +406,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
context 'when rules defined' do context 'when rules defined' do
context 'when a rule is invalid' do context 'when a rule is invalid' do
let(:values) do let(:values) do
{ include: [{ local: 'builds.yml', rules: [{ exists: ['$MY_VAR'] }] }] } { include: [{ local: 'builds.yml', rules: [{ changes: ['$MY_VAR'] }] }] }
end end
it 'raises IncludeError' do it 'raises IncludeError' do
......
# frozen_string_literal: true # frozen_string_literal: true
require 'fast_spec_helper' require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::External::Rules do RSpec.describe Gitlab::Ci::Config::External::Rules do
let(:rule_hashes) {} let(:rule_hashes) {}
...@@ -32,6 +32,26 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do ...@@ -32,6 +32,26 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do
end end
end end
context 'when there is a rule with exists' do
let(:project) { create(:project, :repository) }
let(:context) { double(project: project, sha: project.repository.tree.sha, top_level_worktree_paths: ['test.md']) }
let(:rule_hashes) { [{ exists: 'Dockerfile' }] }
context 'when the file does not exist' do
it { is_expected.to eq(false) }
end
context 'when the file exists' do
let(:context) { double(project: project, sha: project.repository.tree.sha, top_level_worktree_paths: ['Dockerfile']) }
before do
project.repository.create_file(project.owner, 'Dockerfile', "commit", message: 'test', branch_name: "master")
end
it { is_expected.to eq(true) }
end
end
context 'when there is a rule with if and when' do context 'when there is a rule with if and when' do
let(:rule_hashes) { [{ if: '$MY_VAR == "hello"', when: 'on_success' }] } let(:rule_hashes) { [{ if: '$MY_VAR == "hello"', when: 'on_success' }] }
...@@ -41,12 +61,12 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do ...@@ -41,12 +61,12 @@ RSpec.describe Gitlab::Ci::Config::External::Rules do
end end
end end
context 'when there is a rule with exists' do context 'when there is a rule with changes' do
let(:rule_hashes) { [{ exists: ['$MY_VAR'] }] } let(:rule_hashes) { [{ changes: ['$MY_VAR'] }] }
it 'raises an error' do it 'raises an error' do
expect { result }.to raise_error(described_class::InvalidIncludeRulesError, expect { result }.to raise_error(described_class::InvalidIncludeRulesError,
'invalid include rule: {:exists=>["$MY_VAR"]}') 'invalid include rule: {:changes=>["$MY_VAR"]}')
end end
end end
end end
......
...@@ -597,7 +597,7 @@ RSpec.describe Gitlab::Ci::Config do ...@@ -597,7 +597,7 @@ RSpec.describe Gitlab::Ci::Config do
job1: { job1: {
script: ["echo 'hello from main file'"], script: ["echo 'hello from main file'"],
variables: { variables: {
VARIABLE_DEFINED_IN_MAIN_FILE: 'some value' VARIABLE_DEFINED_IN_MAIN_FILE: 'some value'
} }
} }
}) })
...@@ -727,30 +727,70 @@ RSpec.describe Gitlab::Ci::Config do ...@@ -727,30 +727,70 @@ RSpec.describe Gitlab::Ci::Config do
end end
end end
context "when an 'include' has rules with a project variable" do context "when an 'include' has rules" do
let(:gitlab_ci_yml) do context "when the rule is an if" do
<<~HEREDOC let(:gitlab_ci_yml) do
include: <<~HEREDOC
- local: #{local_location} include:
rules: - local: #{local_location}
- if: $CI_PROJECT_ID == "#{project_id}" rules:
image: ruby:2.7 - if: $CI_PROJECT_ID == "#{project_id}"
HEREDOC image: ruby:2.7
end HEREDOC
end
context 'when the rules condition is satisfied' do context 'when the rules condition is satisfied' do
let(:project_id) { project.id } let(:project_id) { project.id }
it 'includes the file' do it 'includes the file' do
expect(config.to_hash).to include(local_location_hash) expect(config.to_hash).to include(local_location_hash)
end
end
context 'when the rules condition is satisfied' do
let(:project_id) { non_existing_record_id }
it 'does not include the file' do
expect(config.to_hash).not_to include(local_location_hash)
end
end end
end end
context 'when the rules condition is satisfied' do context "when the rule is an exists" do
let(:project_id) { non_existing_record_id } let(:gitlab_ci_yml) do
<<~HEREDOC
include:
- local: #{local_location}
rules:
- exists: "#{filename}"
image: ruby:2.7
HEREDOC
end
it 'does not include the file' do before do
expect(config.to_hash).not_to include(local_location_hash) project.repository.create_file(
project.creator,
'my_builds.yml',
local_file_content,
message: 'Add my_builds.yml',
branch_name: '12345'
)
end
context 'when the exists file does not exist' do
let(:filename) { 'not_a_real_file.md' }
it 'does not include the file' do
expect(config.to_hash).not_to include(local_location_hash)
end
end
context 'when the exists file does exist' do
let(:filename) { 'my_builds.yml' }
it 'does include the file' do
expect(config.to_hash).to include(local_location_hash)
end
end 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