Commit 18a42f9f authored by Furkan Ayhan's avatar Furkan Ayhan Committed by Fabio Pitino

Implement including multiple files from a project

This feature is behind a FF "ci_include_multiple_files_from_project"
parent 81854d99
---
name: ci_include_multiple_files_from_project
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45991
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/271560
type: development
group: group::pipeline authoring
default_enabled: false
...@@ -438,6 +438,42 @@ All [nested includes](#nested-includes) are executed in the scope of the target ...@@ -438,6 +438,42 @@ All [nested includes](#nested-includes) are executed in the scope of the target
This means you can use local (relative to target project), project, remote, This means you can use local (relative to target project), project, remote,
or template includes. or template includes.
##### Multiple files from a project
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/26793) in GitLab 13.6.
> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to enable it. **(CORE ONLY)**
You can include multiple files from the same project:
```yaml
include:
- project: 'my-group/my-project'
ref: master
file:
- '/templates/.builds.yml'
- '/templates/.tests.yml'
```
Including multiple files from the same project is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:ci_include_multiple_files_from_project)
```
To disable it:
```ruby
Feature.disable(:ci_include_multiple_files_from_project)
```
#### `include:remote` #### `include:remote`
`include:remote` can be used to include a file from a different location, `include:remote` can be used to include a file from a different location,
......
...@@ -33,6 +33,7 @@ module Gitlab ...@@ -33,6 +33,7 @@ module Gitlab
locations locations
.compact .compact
.map(&method(:normalize_location)) .map(&method(:normalize_location))
.flat_map(&method(:expand_project_files))
.each(&method(:verify_duplicates!)) .each(&method(:verify_duplicates!))
.map(&method(:select_first_matching)) .map(&method(:select_first_matching))
end end
...@@ -52,6 +53,15 @@ module Gitlab ...@@ -52,6 +53,15 @@ module Gitlab
end end
end end
def expand_project_files(location)
return location unless ::Feature.enabled?(:ci_include_multiple_files_from_project, context.project, default_enabled: false)
return location unless location[:project]
Array.wrap(location[:file]).map do |file|
location.merge(file: file)
end
end
def normalize_location_string(location) def normalize_location_string(location)
if ::Gitlab::UrlSanitizer.valid?(location) if ::Gitlab::UrlSanitizer.valid?(location)
{ remote: location } { remote: location }
......
# frozen_string_literal: true
require 'faker'
module QA
RSpec.describe 'Verify', :runner, :requires_admin, :skip_live_env do
describe "Include multiple files from a project" do
let(:feature_flag) { :ci_include_multiple_files_from_project }
let(:executor) { "qa-runner-#{Faker::Alphanumeric.alphanumeric(8)}" }
let(:expected_text) { Faker::Lorem.sentence }
let(:unexpected_text) { Faker::Lorem.sentence }
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'project-with-pipeline-1'
end
end
let(:other_project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'project-with-pipeline-2'
end
end
let!(:runner) do
Resource::Runner.fabricate! do |runner|
runner.project = project
runner.name = executor
runner.tags = [executor]
end
end
before do
Runtime::Feature.enable(feature_flag)
Flow::Login.sign_in
add_included_files
add_main_ci_file
project.visit!
view_the_last_pipeline
end
after do
Runtime::Feature.disable(feature_flag)
runner.remove_via_api!
end
it 'runs the pipeline with composed config', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1082' do
Page::Project::Pipeline::Show.perform do |pipeline|
aggregate_failures 'pipeline has all expected jobs' do
expect(pipeline).to have_job('build')
expect(pipeline).to have_job('test')
expect(pipeline).to have_job('deploy')
end
pipeline.click_job('test')
end
Page::Project::Job::Show.perform do |job|
aggregate_failures 'main CI is not overridden' do
expect(job.output).to have_no_content("#{unexpected_text}")
expect(job.output).to have_content("#{expected_text}")
end
end
end
private
def add_main_ci_file
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.project = project
commit.commit_message = 'Add config file'
commit.add_files([main_ci_file])
end
end
def add_included_files
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.project = other_project
commit.commit_message = 'Add files'
commit.add_files([included_file_1, included_file_2])
end
end
def view_the_last_pipeline
Page::Project::Menu.perform(&:click_ci_cd_pipelines)
Page::Project::Pipeline::Index.perform(&:wait_for_latest_pipeline_success)
Page::Project::Pipeline::Index.perform(&:click_on_latest_pipeline)
end
def main_ci_file
{
file_path: '.gitlab-ci.yml',
content: <<~YAML
include:
- project: #{other_project.full_path}
file:
- file1.yml
- file2.yml
build:
stage: build
tags: ["#{executor}"]
script: echo 'build'
test:
stage: test
tags: ["#{executor}"]
script: echo "#{expected_text}"
YAML
}
end
def included_file_1
{
file_path: 'file1.yml',
content: <<~YAML
test:
stage: test
tags: ["#{executor}"]
script: echo "#{unexpected_text}"
YAML
}
end
def included_file_2
{
file_path: 'file2.yml',
content: <<~YAML
deploy:
stage: deploy
tags: ["#{executor}"]
script: echo 'deploy'
YAML
}
end
end
end
end
...@@ -100,6 +100,42 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do ...@@ -100,6 +100,42 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
expect { subject }.to raise_error(described_class::AmbigiousSpecificationError) expect { subject }.to raise_error(described_class::AmbigiousSpecificationError)
end end
end end
context "when the key is a project's file" do
let(:values) do
{ include: { project: project.full_path, file: local_file },
image: 'ruby:2.7' }
end
it 'returns File instances' do
expect(subject).to contain_exactly(
an_instance_of(Gitlab::Ci::Config::External::File::Project))
end
end
context "when the key is project's files" do
let(:values) do
{ include: { project: project.full_path, file: [local_file, 'another_file_path.yml'] },
image: 'ruby:2.7' }
end
it 'returns two File instances' do
expect(subject).to contain_exactly(
an_instance_of(Gitlab::Ci::Config::External::File::Project),
an_instance_of(Gitlab::Ci::Config::External::File::Project))
end
context 'when FF ci_include_multiple_files_from_project is disabled' do
before do
stub_feature_flags(ci_include_multiple_files_from_project: false)
end
it 'returns a File instance' do
expect(subject).to contain_exactly(
an_instance_of(Gitlab::Ci::Config::External::File::Project))
end
end
end
end end
context "when 'include' is defined as an array" do context "when 'include' is defined as an array" do
...@@ -161,6 +197,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do ...@@ -161,6 +197,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
it 'raises an exception' do it 'raises an exception' do
expect { subject }.to raise_error(described_class::DuplicateIncludesError) expect { subject }.to raise_error(described_class::DuplicateIncludesError)
end end
context 'when including multiple files from a project' do
let(:values) do
{ include: { project: project.full_path, file: [local_file, local_file] } }
end
it 'raises an exception' do
expect { subject }.to raise_error(described_class::DuplicateIncludesError)
end
end
end end
context "when too many 'includes' are defined" do context "when too many 'includes' are defined" do
...@@ -179,6 +225,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do ...@@ -179,6 +225,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
it 'raises an exception' do it 'raises an exception' do
expect { subject }.to raise_error(described_class::TooManyIncludesError) expect { subject }.to raise_error(described_class::TooManyIncludesError)
end end
context 'when including multiple files from a project' do
let(:values) do
{ include: { project: project.full_path, file: [local_file, 'another_file_path.yml'] } }
end
it 'raises an exception' do
expect { subject }.to raise_error(described_class::TooManyIncludesError)
end
end
end end
end end
end end
...@@ -302,5 +302,82 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do ...@@ -302,5 +302,82 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
end end
end end
end end
context 'when a valid project file is defined' do
let(:values) do
{
include: { project: another_project.full_path, file: '/templates/my-build.yml' },
image: 'ruby:2.7'
}
end
before do
another_project.add_developer(user)
allow_next_instance_of(Repository) do |repository|
allow(repository).to receive(:blob_data_at).with(another_project.commit.id, '/templates/my-build.yml') do
<<~HEREDOC
my_build:
script: echo Hello World
HEREDOC
end
end
end
it 'appends the file to the values' do
output = processor.perform
expect(output.keys).to match_array([:image, :my_build])
end
end
context 'when valid project files are defined in a single include' do
let(:values) do
{
include: {
project: another_project.full_path,
file: ['/templates/my-build.yml', '/templates/my-test.yml']
},
image: 'ruby:2.7'
}
end
before do
another_project.add_developer(user)
allow_next_instance_of(Repository) do |repository|
allow(repository).to receive(:blob_data_at).with(another_project.commit.id, '/templates/my-build.yml') do
<<~HEREDOC
my_build:
script: echo Hello World
HEREDOC
end
allow(repository).to receive(:blob_data_at).with(another_project.commit.id, '/templates/my-test.yml') do
<<~HEREDOC
my_test:
script: echo Hello World
HEREDOC
end
end
end
it 'appends the file to the values' do
output = processor.perform
expect(output.keys).to match_array([:image, :my_build, :my_test])
end
context 'when FF ci_include_multiple_files_from_project is disabled' do
before do
stub_feature_flags(ci_include_multiple_files_from_project: false)
end
it 'raises an error' do
expect { processor.perform }.to raise_error(
described_class::IncludeError,
'Included file `["/templates/my-build.yml", "/templates/my-test.yml"]` needs to be a string'
)
end
end
end
end end
end end
...@@ -246,6 +246,14 @@ RSpec.describe Gitlab::Ci::Config do ...@@ -246,6 +246,14 @@ RSpec.describe Gitlab::Ci::Config do
let(:remote_location) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' } let(:remote_location) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' }
let(:local_location) { 'spec/fixtures/gitlab/ci/external_files/.gitlab-ci-template-1.yml' } let(:local_location) { 'spec/fixtures/gitlab/ci/external_files/.gitlab-ci-template-1.yml' }
let(:local_file_content) do
File.read(Rails.root.join(local_location))
end
let(:local_location_hash) do
YAML.safe_load(local_file_content).deep_symbolize_keys
end
let(:remote_file_content) do let(:remote_file_content) do
<<~HEREDOC <<~HEREDOC
variables: variables:
...@@ -256,8 +264,8 @@ RSpec.describe Gitlab::Ci::Config do ...@@ -256,8 +264,8 @@ RSpec.describe Gitlab::Ci::Config do
HEREDOC HEREDOC
end end
let(:local_file_content) do let(:remote_file_hash) do
File.read(Rails.root.join(local_location)) YAML.safe_load(remote_file_content).deep_symbolize_keys
end end
let(:gitlab_ci_yml) do let(:gitlab_ci_yml) do
...@@ -283,22 +291,11 @@ RSpec.describe Gitlab::Ci::Config do ...@@ -283,22 +291,11 @@ RSpec.describe Gitlab::Ci::Config do
context "when gitlab_ci_yml has valid 'include' defined" do context "when gitlab_ci_yml has valid 'include' defined" do
it 'returns a composed hash' do it 'returns a composed hash' do
before_script_values = [
"apt-get update -qq && apt-get install -y -qq sqlite3 libsqlite3-dev nodejs", "ruby -v",
"which ruby",
"bundle install --jobs $(nproc) \"${FLAGS[@]}\""
]
variables = {
POSTGRES_USER: "user",
POSTGRES_PASSWORD: "testing-password",
POSTGRES_ENABLED: "true",
POSTGRES_DB: "$CI_ENVIRONMENT_SLUG"
}
composed_hash = { composed_hash = {
before_script: before_script_values, before_script: local_location_hash[:before_script],
image: "ruby:2.7", image: "ruby:2.7",
rspec: { script: ["bundle exec rspec"] }, rspec: { script: ["bundle exec rspec"] },
variables: variables variables: remote_file_hash[:variables]
} }
expect(config.to_hash).to eq(composed_hash) expect(config.to_hash).to eq(composed_hash)
...@@ -575,5 +572,56 @@ RSpec.describe Gitlab::Ci::Config do ...@@ -575,5 +572,56 @@ RSpec.describe Gitlab::Ci::Config do
) )
end end
end end
context "when including multiple files from a project" do
let(:other_file_location) { 'my_builds.yml' }
let(:other_file_content) do
<<~HEREDOC
build:
stage: build
script: echo hello
rspec:
stage: test
script: bundle exec rspec
HEREDOC
end
let(:gitlab_ci_yml) do
<<~HEREDOC
include:
- project: #{project.full_path}
file:
- #{local_location}
- #{other_file_location}
image: ruby:2.7
HEREDOC
end
before do
project.add_developer(user)
allow_next_instance_of(Repository) do |repository|
allow(repository).to receive(:blob_data_at).with(an_instance_of(String), local_location)
.and_return(local_file_content)
allow(repository).to receive(:blob_data_at).with(an_instance_of(String), other_file_location)
.and_return(other_file_content)
end
end
it 'returns a composed hash' do
composed_hash = {
before_script: local_location_hash[:before_script],
image: "ruby:2.7",
build: { stage: "build", script: "echo hello" },
rspec: { stage: "test", script: "bundle exec rspec" }
}
expect(config.to_hash).to eq(composed_hash)
end
end
end end
end end
...@@ -279,6 +279,40 @@ RSpec.describe Ci::CreatePipelineService, '#execute' do ...@@ -279,6 +279,40 @@ RSpec.describe Ci::CreatePipelineService, '#execute' do
end end
end end
end end
context 'when specifying multiple files' do
let(:config) do
<<~YAML
test:
script: rspec
deploy:
variables:
CROSS: downstream
stage: deploy
trigger:
include:
- project: my-namespace/my-project
file:
- 'path/to/child1.yml'
- 'path/to/child2.yml'
YAML
end
it_behaves_like 'successful creation' do
let(:expected_bridge_options) do
{
'trigger' => {
'include' => [
{
'file' => ["path/to/child1.yml", "path/to/child2.yml"],
'project' => 'my-namespace/my-project'
}
]
}
}
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