1468 lines
43 KiB
Ruby
1468 lines
43 KiB
Ruby
# frozen_string_literal: true
|
|
require 'spec_helper'
|
|
|
|
RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectness, feature_category: :pipeline_authoring do
|
|
let(:project) { create(:project, :repository) }
|
|
let(:user) { project.first_owner }
|
|
let(:ref) { 'refs/heads/master' }
|
|
let(:source) { :push }
|
|
let(:service) { described_class.new(project, user, { ref: ref }) }
|
|
let(:response) { execute_service }
|
|
let(:pipeline) { response.payload }
|
|
let(:build_names) { pipeline.builds.pluck(:name) }
|
|
|
|
def execute_service(before: '00000000', variables_attributes: nil)
|
|
params = { ref: ref, before: before, after: project.commit(ref).sha, variables_attributes: variables_attributes }
|
|
|
|
described_class
|
|
.new(project, user, params)
|
|
.execute(source) do |pipeline|
|
|
yield(pipeline) if block_given?
|
|
end
|
|
end
|
|
|
|
context 'job:rules' do
|
|
let(:regular_job) { find_job('regular-job') }
|
|
let(:rules_job) { find_job('rules-job') }
|
|
let(:delayed_job) { find_job('delayed-job') }
|
|
|
|
def find_job(name)
|
|
pipeline.builds.find_by(name: name)
|
|
end
|
|
|
|
shared_examples 'rules jobs are excluded' do
|
|
it 'only persists the job without rules' do
|
|
expect(pipeline).to be_persisted
|
|
expect(regular_job).to be_persisted
|
|
expect(rules_job).to be_nil
|
|
expect(delayed_job).to be_nil
|
|
end
|
|
end
|
|
|
|
before do
|
|
stub_ci_pipeline_yaml_file(config)
|
|
allow_next_instance_of(Ci::BuildScheduleWorker) do |instance|
|
|
allow(instance).to receive(:perform).and_return(true)
|
|
end
|
|
end
|
|
|
|
context 'exists:' do
|
|
let(:config) do
|
|
<<-EOY
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
|
|
rules-job:
|
|
script: "echo hello world, $CI_COMMIT_REF_NAME"
|
|
rules:
|
|
- exists:
|
|
- README.md
|
|
when: manual
|
|
- exists:
|
|
- app.rb
|
|
when: on_success
|
|
|
|
delayed-job:
|
|
script: "echo See you later, World!"
|
|
rules:
|
|
- exists:
|
|
- README.md
|
|
when: delayed
|
|
start_in: 4 hours
|
|
EOY
|
|
end
|
|
|
|
let(:regular_job) { pipeline.builds.find_by(name: 'regular-job') }
|
|
let(:rules_job) { pipeline.builds.find_by(name: 'rules-job') }
|
|
let(:delayed_job) { pipeline.builds.find_by(name: 'delayed-job') }
|
|
|
|
context 'with matches' do
|
|
let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) }
|
|
|
|
it 'creates two jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job', 'rules-job', 'delayed-job')
|
|
end
|
|
|
|
it 'sets when: for all jobs' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
expect(rules_job.when).to eq('manual')
|
|
expect(delayed_job.when).to eq('delayed')
|
|
expect(delayed_job.options[:start_in]).to eq('4 hours')
|
|
end
|
|
end
|
|
|
|
context 'with matches on the second rule' do
|
|
let(:project) { create(:project, :custom_repo, files: { 'app.rb' => '' }) }
|
|
|
|
it 'includes both jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job', 'rules-job')
|
|
end
|
|
|
|
it 'sets when: for the created rules job based on the second clause' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
expect(rules_job.when).to eq('on_success')
|
|
end
|
|
end
|
|
|
|
context 'without matches' do
|
|
let(:project) { create(:project, :custom_repo, files: { 'useless_script.rb' => '' }) }
|
|
|
|
it 'only persists the job without rules' do
|
|
expect(pipeline).to be_persisted
|
|
expect(regular_job).to be_persisted
|
|
expect(rules_job).to be_nil
|
|
expect(delayed_job).to be_nil
|
|
end
|
|
|
|
it 'sets when: for the created job' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with allow_failure and exit_codes', :aggregate_failures do
|
|
let(:config) do
|
|
<<-EOY
|
|
job-1:
|
|
script: exit 42
|
|
allow_failure:
|
|
exit_codes: 42
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME == "master"
|
|
allow_failure: false
|
|
|
|
job-2:
|
|
script: exit 42
|
|
allow_failure:
|
|
exit_codes: 42
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME == "master"
|
|
allow_failure: true
|
|
|
|
job-3:
|
|
script: exit 42
|
|
allow_failure:
|
|
exit_codes: 42
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME == "master"
|
|
when: manual
|
|
EOY
|
|
end
|
|
|
|
it 'creates a pipeline' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly(
|
|
'job-1', 'job-2', 'job-3'
|
|
)
|
|
end
|
|
|
|
it 'assigns job:allow_failure values to the builds' do
|
|
expect(find_job('job-1').allow_failure).to eq(false)
|
|
expect(find_job('job-2').allow_failure).to eq(true)
|
|
expect(find_job('job-3').allow_failure).to eq(false)
|
|
end
|
|
|
|
it 'removes exit_codes if allow_failure is specified' do
|
|
expect(find_job('job-1').options.dig(:allow_failure_criteria)).to be_nil
|
|
expect(find_job('job-2').options.dig(:allow_failure_criteria)).to be_nil
|
|
expect(find_job('job-3').options.dig(:allow_failure_criteria, :exit_codes)).to eq([42])
|
|
end
|
|
end
|
|
|
|
context 'if:' do
|
|
context 'variables:' do
|
|
let(:config) do
|
|
<<-EOY
|
|
variables:
|
|
VAR4: workflow var 4
|
|
VAR5: workflow var 5
|
|
VAR7: workflow var 7
|
|
|
|
workflow:
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
variables:
|
|
VAR4: overridden workflow var 4
|
|
- if: $CI_COMMIT_REF_NAME =~ /feature/
|
|
variables:
|
|
VAR5: overridden workflow var 5
|
|
VAR6: new workflow var 6
|
|
VAR7: overridden workflow var 7
|
|
- when: always
|
|
|
|
job1:
|
|
script: "echo job1"
|
|
variables:
|
|
VAR1: job var 1
|
|
VAR2: job var 2
|
|
VAR5: job var 5
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
variables:
|
|
VAR1: overridden var 1
|
|
- if: $CI_COMMIT_REF_NAME =~ /feature/
|
|
variables:
|
|
VAR2: overridden var 2
|
|
VAR3: new var 3
|
|
VAR7: overridden var 7
|
|
- when: on_success
|
|
|
|
job2:
|
|
script: "echo job2"
|
|
inherit:
|
|
variables: [VAR4, VAR6, VAR7]
|
|
variables:
|
|
VAR4: job var 4
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
variables:
|
|
VAR7: overridden var 7
|
|
- when: on_success
|
|
EOY
|
|
end
|
|
|
|
let(:job1) { pipeline.builds.find_by(name: 'job1') }
|
|
let(:job2) { pipeline.builds.find_by(name: 'job2') }
|
|
|
|
let(:variable_keys) { %w(VAR1 VAR2 VAR3 VAR4 VAR5 VAR6 VAR7) }
|
|
|
|
context 'when no match' do
|
|
let(:ref) { 'refs/heads/wip' }
|
|
|
|
it 'does not affect vars' do
|
|
expect(job1.scoped_variables.to_hash.values_at(*variable_keys)).to eq(
|
|
['job var 1', 'job var 2', nil, 'workflow var 4', 'job var 5', nil, 'workflow var 7']
|
|
)
|
|
|
|
expect(job2.scoped_variables.to_hash.values_at(*variable_keys)).to eq(
|
|
[nil, nil, nil, 'job var 4', nil, nil, 'workflow var 7']
|
|
)
|
|
end
|
|
end
|
|
|
|
context 'when matching to the first rule' do
|
|
let(:ref) { 'refs/heads/master' }
|
|
|
|
it 'overrides variables' do
|
|
expect(job1.scoped_variables.to_hash.values_at(*variable_keys)).to eq(
|
|
['overridden var 1', 'job var 2', nil, 'overridden workflow var 4', 'job var 5', nil, 'workflow var 7']
|
|
)
|
|
|
|
expect(job2.scoped_variables.to_hash.values_at(*variable_keys)).to eq(
|
|
[nil, nil, nil, 'job var 4', nil, nil, 'overridden var 7']
|
|
)
|
|
end
|
|
end
|
|
|
|
context 'when matching to the second rule' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
it 'overrides variables' do
|
|
expect(job1.scoped_variables.to_hash.values_at(*variable_keys)).to eq(
|
|
['job var 1', 'overridden var 2', 'new var 3', 'workflow var 4', 'job var 5', 'new workflow var 6', 'overridden var 7']
|
|
)
|
|
|
|
expect(job2.scoped_variables.to_hash.values_at(*variable_keys)).to eq(
|
|
[nil, nil, nil, 'job var 4', nil, 'new workflow var 6', 'overridden workflow var 7']
|
|
)
|
|
end
|
|
end
|
|
|
|
context 'using calculated workflow var in job rules' do
|
|
let(:config) do
|
|
<<-EOY
|
|
variables:
|
|
VAR1: workflow var 4
|
|
|
|
workflow:
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
variables:
|
|
VAR1: overridden workflow var 4
|
|
- when: always
|
|
|
|
job:
|
|
script: "echo job1"
|
|
rules:
|
|
- if: $VAR1 =~ "overridden workflow var 4"
|
|
variables:
|
|
VAR1: overridden var 1
|
|
- when: on_success
|
|
EOY
|
|
end
|
|
|
|
let(:job) { pipeline.builds.find_by(name: 'job') }
|
|
|
|
context 'when matching the first workflow condition' do
|
|
let(:ref) { 'refs/heads/master' }
|
|
|
|
it 'uses VAR1 of job rules result' do
|
|
expect(job.scoped_variables.to_hash['VAR1']).to eq('overridden var 1')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with simple if: clauses' do
|
|
let(:config) do
|
|
<<-EOY
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
|
|
master-job:
|
|
script: "echo hello world, $CI_COMMIT_REF_NAME"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME == "nonexistant-branch"
|
|
when: never
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: manual
|
|
|
|
negligible-job:
|
|
script: "exit 1"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
allow_failure: true
|
|
|
|
delayed-job:
|
|
script: "echo See you later, World!"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: delayed
|
|
start_in: 1 hour
|
|
|
|
never-job:
|
|
script: "echo Goodbye, World!"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME
|
|
when: never
|
|
EOY
|
|
end
|
|
|
|
context 'with matches' do
|
|
it 'creates a pipeline with the vanilla and manual jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly(
|
|
'regular-job', 'delayed-job', 'master-job', 'negligible-job'
|
|
)
|
|
end
|
|
|
|
it 'assigns job:when values to the builds' do
|
|
expect(find_job('regular-job').when).to eq('on_success')
|
|
expect(find_job('master-job').when).to eq('manual')
|
|
expect(find_job('negligible-job').when).to eq('on_success')
|
|
expect(find_job('delayed-job').when).to eq('delayed')
|
|
end
|
|
|
|
it 'assigns job:allow_failure values to the builds' do
|
|
expect(find_job('regular-job').allow_failure).to eq(false)
|
|
expect(find_job('master-job').allow_failure).to eq(false)
|
|
expect(find_job('negligible-job').allow_failure).to eq(true)
|
|
expect(find_job('delayed-job').allow_failure).to eq(false)
|
|
end
|
|
|
|
it 'assigns start_in for delayed jobs' do
|
|
expect(delayed_job.options[:start_in]).to eq('1 hour')
|
|
end
|
|
end
|
|
|
|
context 'with no matches' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
it_behaves_like 'rules jobs are excluded'
|
|
end
|
|
end
|
|
|
|
context 'with complex if: clauses' do
|
|
let(:config) do
|
|
<<-EOY
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
rules:
|
|
- if: $VAR == 'present' && $OTHER || $CI_COMMIT_REF_NAME
|
|
when: manual
|
|
allow_failure: true
|
|
EOY
|
|
end
|
|
|
|
it 'matches the first rule' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job')
|
|
expect(regular_job.when).to eq('manual')
|
|
expect(regular_job.allow_failure).to eq(true)
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'changes:' do
|
|
let(:config) do
|
|
<<-EOY
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
|
|
rules-job:
|
|
script: "echo hello world, $CI_COMMIT_REF_NAME"
|
|
rules:
|
|
- changes:
|
|
- README.md
|
|
when: manual
|
|
- changes:
|
|
- app.rb
|
|
when: on_success
|
|
|
|
delayed-job:
|
|
script: "echo See you later, World!"
|
|
rules:
|
|
- changes:
|
|
- README.md
|
|
when: delayed
|
|
start_in: 4 hours
|
|
|
|
negligible-job:
|
|
script: "can be failed sometimes"
|
|
rules:
|
|
- changes:
|
|
- README.md
|
|
allow_failure: true
|
|
|
|
README:
|
|
script: "I use variables for changes!"
|
|
rules:
|
|
- changes:
|
|
- $CI_JOB_NAME*
|
|
|
|
changes-paths:
|
|
script: "I am using a new syntax!"
|
|
rules:
|
|
- changes:
|
|
paths: [README.md]
|
|
EOY
|
|
end
|
|
|
|
context 'and matches' do
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[README.md])
|
|
end
|
|
end
|
|
|
|
it 'creates five jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly(
|
|
'regular-job', 'rules-job', 'delayed-job', 'negligible-job', 'README', 'changes-paths'
|
|
)
|
|
end
|
|
|
|
it 'sets when: for all jobs' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
expect(rules_job.when).to eq('manual')
|
|
expect(delayed_job.when).to eq('delayed')
|
|
expect(delayed_job.options[:start_in]).to eq('4 hours')
|
|
end
|
|
|
|
it 'sets allow_failure: for negligible job' do
|
|
expect(find_job('negligible-job').allow_failure).to eq(true)
|
|
end
|
|
end
|
|
|
|
context 'and matches the second rule' do
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[app.rb])
|
|
end
|
|
end
|
|
|
|
it 'includes both jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job', 'rules-job')
|
|
end
|
|
|
|
it 'sets when: for the created rules job based on the second clause' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
expect(rules_job.when).to eq('on_success')
|
|
end
|
|
end
|
|
|
|
context 'and does not match' do
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[useless_script.rb])
|
|
end
|
|
end
|
|
|
|
it_behaves_like 'rules jobs are excluded'
|
|
|
|
it 'sets when: for the created job' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
end
|
|
end
|
|
|
|
context 'with paths and compare_to' do
|
|
let_it_be(:project) { create(:project, :empty_repo) }
|
|
let_it_be(:user) { project.first_owner }
|
|
|
|
before_all do
|
|
project.repository.add_branch(user, 'feature_1', 'master')
|
|
|
|
project.repository.create_file(
|
|
user, 'file1.txt', 'file 1', message: 'Create file1.txt', branch_name: 'feature_1'
|
|
)
|
|
|
|
project.repository.add_branch(user, 'feature_2', 'feature_1')
|
|
|
|
project.repository.create_file(
|
|
user, 'file2.txt', 'file 2', message: 'Create file2.txt', branch_name: 'feature_2'
|
|
)
|
|
end
|
|
|
|
let(:changed_file) { 'file2.txt' }
|
|
let(:ref) { 'feature_2' }
|
|
|
|
let(:response) { execute_service(before: nil) }
|
|
|
|
context 'for jobs rules' do
|
|
let(:config) do
|
|
<<-EOY
|
|
job1:
|
|
script: exit 0
|
|
rules:
|
|
- changes:
|
|
paths: [#{changed_file}]
|
|
compare_to: #{compare_to}
|
|
|
|
job2:
|
|
script: exit 0
|
|
EOY
|
|
end
|
|
|
|
context 'when there is no such compare_to ref' do
|
|
let(:compare_to) { 'invalid-branch' }
|
|
|
|
it 'returns an error' do
|
|
expect(pipeline.errors.full_messages).to eq(
|
|
[
|
|
'Failed to parse rule for job1: rules:changes:compare_to is not a valid ref'
|
|
])
|
|
end
|
|
end
|
|
|
|
context 'when the compare_to ref exists' do
|
|
let(:compare_to) { 'feature_1' }
|
|
|
|
context 'when the rule matches' do
|
|
it 'creates job1 and job2' do
|
|
expect(build_names).to contain_exactly('job1', 'job2')
|
|
end
|
|
end
|
|
|
|
context 'when the rule does not match' do
|
|
let(:changed_file) { 'file1.txt' }
|
|
|
|
it 'does not create job1' do
|
|
expect(build_names).to contain_exactly('job2')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'for workflow rules' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- changes:
|
|
paths: [#{changed_file}]
|
|
compare_to: #{compare_to}
|
|
|
|
job1:
|
|
script: exit 0
|
|
EOY
|
|
end
|
|
|
|
let(:compare_to) { 'feature_1' }
|
|
|
|
context 'when the rule matches' do
|
|
it 'creates job1' do
|
|
expect(pipeline).to be_created_successfully
|
|
expect(build_names).to contain_exactly('job1')
|
|
end
|
|
end
|
|
|
|
context 'when the rule does not match' do
|
|
let(:changed_file) { 'file1.txt' }
|
|
|
|
it 'does not create job1' do
|
|
expect(pipeline).not_to be_created_successfully
|
|
expect(build_names).to be_empty
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'mixed if: and changes: rules' do
|
|
let(:config) do
|
|
<<-EOY
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
|
|
rules-job:
|
|
script: "echo hello world, $CI_COMMIT_REF_NAME"
|
|
allow_failure: true
|
|
rules:
|
|
- changes:
|
|
- README.md
|
|
when: manual
|
|
- if: $CI_COMMIT_REF_NAME == "master"
|
|
when: on_success
|
|
allow_failure: false
|
|
|
|
delayed-job:
|
|
script: "echo See you later, World!"
|
|
rules:
|
|
- changes:
|
|
- README.md
|
|
when: delayed
|
|
start_in: 4 hours
|
|
allow_failure: true
|
|
- if: $CI_COMMIT_REF_NAME == "master"
|
|
when: delayed
|
|
start_in: 1 hour
|
|
EOY
|
|
end
|
|
|
|
context 'and changes: matches before if' do
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[README.md])
|
|
end
|
|
end
|
|
|
|
it 'creates two jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names)
|
|
.to contain_exactly('regular-job', 'rules-job', 'delayed-job')
|
|
end
|
|
|
|
it 'sets when: for all jobs' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
expect(rules_job.when).to eq('manual')
|
|
expect(delayed_job.when).to eq('delayed')
|
|
expect(delayed_job.options[:start_in]).to eq('4 hours')
|
|
end
|
|
|
|
it 'sets allow_failure: for all jobs' do
|
|
expect(regular_job.allow_failure).to eq(false)
|
|
expect(rules_job.allow_failure).to eq(true)
|
|
expect(delayed_job.allow_failure).to eq(true)
|
|
end
|
|
end
|
|
|
|
context 'and if: matches after changes' do
|
|
it 'includes both jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job', 'rules-job', 'delayed-job')
|
|
end
|
|
|
|
it 'sets when: for the created rules job based on the second clause' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
expect(rules_job.when).to eq('on_success')
|
|
expect(delayed_job.when).to eq('delayed')
|
|
expect(delayed_job.options[:start_in]).to eq('1 hour')
|
|
end
|
|
end
|
|
|
|
context 'and does not match' do
|
|
let(:ref) { 'refs/heads/wip' }
|
|
|
|
it_behaves_like 'rules jobs are excluded'
|
|
|
|
it 'sets when: for the created job' do
|
|
expect(regular_job.when).to eq('on_success')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'mixed if: and changes: clauses' do
|
|
let(:config) do
|
|
<<-EOY
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
|
|
rules-job:
|
|
script: "echo hello world, $CI_COMMIT_REF_NAME"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
changes: [README.md]
|
|
when: on_success
|
|
allow_failure: true
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
changes: [app.rb]
|
|
when: manual
|
|
EOY
|
|
end
|
|
|
|
context 'with if matches and changes matches' do
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[app.rb])
|
|
end
|
|
end
|
|
|
|
it 'persists all jobs' do
|
|
expect(pipeline).to be_persisted
|
|
expect(regular_job).to be_persisted
|
|
expect(rules_job).to be_persisted
|
|
expect(rules_job.when).to eq('manual')
|
|
expect(rules_job.allow_failure).to eq(false)
|
|
end
|
|
end
|
|
|
|
context 'with if matches and no change matches' do
|
|
it_behaves_like 'rules jobs are excluded'
|
|
end
|
|
|
|
context 'with change matches and no if matches' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[README.md])
|
|
end
|
|
end
|
|
|
|
it_behaves_like 'rules jobs are excluded'
|
|
end
|
|
|
|
context 'and no matches' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
it_behaves_like 'rules jobs are excluded'
|
|
end
|
|
end
|
|
|
|
context 'complex if: allow_failure usages' do
|
|
let(:config) do
|
|
<<-EOY
|
|
job-1:
|
|
script: "exit 1"
|
|
allow_failure: true
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
allow_failure: false
|
|
|
|
job-2:
|
|
script: "exit 1"
|
|
allow_failure: true
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /nonexistant-branch/
|
|
allow_failure: false
|
|
|
|
job-3:
|
|
script: "exit 1"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /nonexistant-branch/
|
|
allow_failure: true
|
|
|
|
job-4:
|
|
script: "exit 1"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
allow_failure: false
|
|
|
|
job-5:
|
|
script: "exit 1"
|
|
allow_failure: false
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
allow_failure: true
|
|
|
|
job-6:
|
|
script: "exit 1"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /nonexistant-branch/
|
|
allow_failure: false
|
|
- allow_failure: true
|
|
EOY
|
|
end
|
|
|
|
it 'creates a pipeline' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('job-1', 'job-4', 'job-5', 'job-6')
|
|
end
|
|
|
|
it 'assigns job:allow_failure values to the builds' do
|
|
expect(find_job('job-1').allow_failure).to eq(false)
|
|
expect(find_job('job-4').allow_failure).to eq(false)
|
|
expect(find_job('job-5').allow_failure).to eq(true)
|
|
expect(find_job('job-6').allow_failure).to eq(true)
|
|
end
|
|
end
|
|
|
|
context 'complex if: allow_failure & when usages' do
|
|
let(:config) do
|
|
<<-EOY
|
|
job-1:
|
|
script: "exit 1"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: manual
|
|
|
|
job-2:
|
|
script: "exit 1"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: manual
|
|
allow_failure: true
|
|
|
|
job-3:
|
|
script: "exit 1"
|
|
allow_failure: true
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: manual
|
|
|
|
job-4:
|
|
script: "exit 1"
|
|
allow_failure: true
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: manual
|
|
allow_failure: false
|
|
|
|
job-5:
|
|
script: "exit 1"
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /nonexistant-branch/
|
|
when: manual
|
|
allow_failure: false
|
|
- when: always
|
|
allow_failure: true
|
|
|
|
job-6:
|
|
script: "exit 1"
|
|
allow_failure: false
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: manual
|
|
|
|
job-7:
|
|
script: "exit 1"
|
|
allow_failure: false
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /nonexistant-branch/
|
|
when: manual
|
|
- when: :on_failure
|
|
allow_failure: true
|
|
EOY
|
|
end
|
|
|
|
it 'creates a pipeline' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly(
|
|
'job-1', 'job-2', 'job-3', 'job-4', 'job-5', 'job-6', 'job-7'
|
|
)
|
|
end
|
|
|
|
it 'assigns job:allow_failure values to the builds' do
|
|
expect(find_job('job-1').allow_failure).to eq(false)
|
|
expect(find_job('job-2').allow_failure).to eq(true)
|
|
expect(find_job('job-3').allow_failure).to eq(true)
|
|
expect(find_job('job-4').allow_failure).to eq(false)
|
|
expect(find_job('job-5').allow_failure).to eq(true)
|
|
expect(find_job('job-6').allow_failure).to eq(false)
|
|
expect(find_job('job-7').allow_failure).to eq(true)
|
|
end
|
|
|
|
it 'assigns job:when values to the builds' do
|
|
expect(find_job('job-1').when).to eq('manual')
|
|
expect(find_job('job-2').when).to eq('manual')
|
|
expect(find_job('job-3').when).to eq('manual')
|
|
expect(find_job('job-4').when).to eq('manual')
|
|
expect(find_job('job-5').when).to eq('always')
|
|
expect(find_job('job-6').when).to eq('manual')
|
|
expect(find_job('job-7').when).to eq('on_failure')
|
|
end
|
|
end
|
|
|
|
context 'deploy freeze period `if:` clause' do
|
|
# '0 23 * * 5' == "At 23:00 on Friday."", '0 7 * * 1' == "At 07:00 on Monday.""
|
|
let!(:freeze_period) { create(:ci_freeze_period, project: project, freeze_start: '0 23 * * 5', freeze_end: '0 7 * * 1') }
|
|
|
|
context 'with 2 jobs' do
|
|
let(:config) do
|
|
<<-EOY
|
|
stages:
|
|
- test
|
|
- deploy
|
|
|
|
test-job:
|
|
script:
|
|
- echo 'running TEST stage'
|
|
|
|
deploy-job:
|
|
stage: deploy
|
|
script:
|
|
- echo 'running DEPLOY stage'
|
|
rules:
|
|
- if: $CI_DEPLOY_FREEZE == null
|
|
EOY
|
|
end
|
|
|
|
context 'when outside freeze period' do
|
|
it 'creates two jobs' do
|
|
travel_to(Time.utc(2020, 4, 10, 22, 59)) do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('test-job', 'deploy-job')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when inside freeze period' do
|
|
it 'creates one job' do
|
|
travel_to(Time.utc(2020, 4, 10, 23, 1)) do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('test-job')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with 1 job' do
|
|
let(:config) do
|
|
<<-EOY
|
|
stages:
|
|
- deploy
|
|
|
|
deploy-job:
|
|
stage: deploy
|
|
script:
|
|
- echo 'running DEPLOY stage'
|
|
rules:
|
|
- if: $CI_DEPLOY_FREEZE == null
|
|
EOY
|
|
end
|
|
|
|
context 'when outside freeze period' do
|
|
it 'creates two jobs' do
|
|
travel_to(Time.utc(2020, 4, 10, 22, 59)) do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('deploy-job')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when inside freeze period' do
|
|
it 'does not create the pipeline', :aggregate_failures do
|
|
travel_to(Time.utc(2020, 4, 10, 23, 1)) do
|
|
expect(response).to be_error
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with when:manual' do
|
|
let(:config) do
|
|
<<-EOY
|
|
job-with-rules:
|
|
script: 'echo hey'
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
|
|
job-when-with-rules:
|
|
script: 'echo hey'
|
|
when: manual
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
|
|
job-when-with-rules-when:
|
|
script: 'echo hey'
|
|
when: manual
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: on_success
|
|
|
|
job-with-rules-when:
|
|
script: 'echo hey'
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
when: manual
|
|
|
|
job-without-rules:
|
|
script: 'echo this is a job with NO rules'
|
|
EOY
|
|
end
|
|
|
|
let(:job_with_rules) { find_job('job-with-rules') }
|
|
let(:job_when_with_rules) { find_job('job-when-with-rules') }
|
|
let(:job_when_with_rules_when) { find_job('job-when-with-rules-when') }
|
|
let(:job_with_rules_when) { find_job('job-with-rules-when') }
|
|
let(:job_without_rules) { find_job('job-without-rules') }
|
|
|
|
context 'when matching the rules' do
|
|
let(:ref) { 'refs/heads/master' }
|
|
|
|
it 'adds the job-with-rules with a when:manual' do
|
|
expect(job_with_rules).to be_persisted
|
|
expect(job_when_with_rules).to be_persisted
|
|
expect(job_when_with_rules_when).to be_persisted
|
|
expect(job_with_rules_when).to be_persisted
|
|
expect(job_without_rules).to be_persisted
|
|
|
|
expect(job_with_rules.when).to eq('on_success')
|
|
expect(job_when_with_rules.when).to eq('manual')
|
|
expect(job_when_with_rules_when.when).to eq('on_success')
|
|
expect(job_with_rules_when.when).to eq('manual')
|
|
expect(job_without_rules.when).to eq('on_success')
|
|
end
|
|
end
|
|
|
|
context 'when there is no match to the rule' do
|
|
let(:ref) { 'refs/heads/wip' }
|
|
|
|
it 'does not add job_with_rules' do
|
|
expect(job_with_rules).to be_nil
|
|
expect(job_when_with_rules).to be_nil
|
|
expect(job_when_with_rules_when).to be_nil
|
|
expect(job_with_rules_when).to be_nil
|
|
expect(job_without_rules).to be_persisted
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when workflow:rules are used' do
|
|
before do
|
|
stub_ci_pipeline_yaml_file(config)
|
|
end
|
|
|
|
context 'with a single regex-matching if: clause' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
- if: $CI_COMMIT_REF_NAME =~ /wip$/
|
|
when: never
|
|
- if: $CI_COMMIT_REF_NAME =~ /feature/
|
|
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
EOY
|
|
end
|
|
|
|
context 'matching the first rule in the list' do
|
|
it 'saves a created pipeline' do
|
|
expect(pipeline).to be_created
|
|
expect(pipeline).to be_persisted
|
|
end
|
|
end
|
|
|
|
context 'matching the last rule in the list' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
it 'saves a created pipeline' do
|
|
expect(pipeline).to be_created
|
|
expect(pipeline).to be_persisted
|
|
end
|
|
end
|
|
|
|
context 'matching the when:never rule' do
|
|
let(:ref) { 'refs/heads/wip' }
|
|
|
|
it 'invalidates the pipeline with a workflow rules error' do
|
|
expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.')
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
|
|
context 'matching no rules in the list' do
|
|
let(:ref) { 'refs/heads/fix' }
|
|
|
|
it 'invalidates the pipeline with a workflow rules error' do
|
|
expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.')
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when root variables are used' do
|
|
let(:config) do
|
|
<<-EOY
|
|
variables:
|
|
VARIABLE: value
|
|
|
|
workflow:
|
|
rules:
|
|
- if: $VARIABLE
|
|
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
EOY
|
|
end
|
|
|
|
context 'matching the first rule in the list' do
|
|
it 'saves a created pipeline' do
|
|
expect(pipeline).to be_created
|
|
expect(pipeline).to be_persisted
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with a multiple regex-matching if: clause' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
- if: $CI_COMMIT_REF_NAME =~ /^feature/ && $CI_COMMIT_REF_NAME =~ /conflict$/
|
|
when: never
|
|
- if: $CI_COMMIT_REF_NAME =~ /feature/
|
|
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
EOY
|
|
end
|
|
|
|
context 'with partial match' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
it 'saves a created pipeline' do
|
|
expect(pipeline).to be_created
|
|
expect(pipeline).to be_persisted
|
|
end
|
|
end
|
|
|
|
context 'with complete match' do
|
|
let(:ref) { 'refs/heads/feature_conflict' }
|
|
|
|
it 'invalidates the pipeline with a workflow rules error' do
|
|
expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.')
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with job rules' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
- if: $CI_COMMIT_REF_NAME =~ /feature/
|
|
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /wip/
|
|
- if: $CI_COMMIT_REF_NAME =~ /feature/
|
|
EOY
|
|
end
|
|
|
|
context 'where workflow passes and the job fails' do
|
|
let(:ref) { 'refs/heads/master' }
|
|
|
|
it 'invalidates the pipeline with an empty jobs error' do
|
|
expect(pipeline.errors[:base]).to include('Pipeline will not run for the selected trigger. ' \
|
|
'The rules configuration prevented any jobs from being added to the pipeline.')
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
|
|
context 'where workflow passes and the job passes' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
it 'saves a created pipeline' do
|
|
expect(pipeline).to be_created
|
|
expect(pipeline).to be_persisted
|
|
end
|
|
end
|
|
|
|
context 'where workflow fails and the job fails' do
|
|
let(:ref) { 'refs/heads/fix' }
|
|
|
|
it 'invalidates the pipeline with a workflow rules error' do
|
|
expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.')
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
|
|
context 'where workflow fails and the job passes' do
|
|
let(:ref) { 'refs/heads/wip' }
|
|
|
|
it 'invalidates the pipeline with a workflow rules error' do
|
|
expect(pipeline.errors[:base]).to include('Pipeline filtered out by workflow rules.')
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with persisted variables' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME == "master"
|
|
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
EOY
|
|
end
|
|
|
|
context 'with matches' do
|
|
it 'creates a pipeline' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job')
|
|
end
|
|
end
|
|
|
|
context 'with no matches' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
it 'does not create a pipeline', :aggregate_failures do
|
|
expect(response).to be_error
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with pipeline variables' do
|
|
let(:pipeline) do
|
|
execute_service(variables_attributes: variables_attributes).payload
|
|
end
|
|
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $SOME_VARIABLE
|
|
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
EOY
|
|
end
|
|
|
|
context 'with matches' do
|
|
let(:variables_attributes) do
|
|
[{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAR' }]
|
|
end
|
|
|
|
it 'creates a pipeline' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job')
|
|
end
|
|
end
|
|
|
|
context 'with no matches' do
|
|
let(:variables_attributes) { {} }
|
|
|
|
it 'does not create a pipeline', :aggregate_failures do
|
|
expect(response).to be_error
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with trigger variables' do
|
|
let(:pipeline) do
|
|
execute_service do |pipeline|
|
|
pipeline.variables.build(variables)
|
|
end.payload
|
|
end
|
|
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $SOME_VARIABLE
|
|
|
|
regular-job:
|
|
script: 'echo Hello, World!'
|
|
EOY
|
|
end
|
|
|
|
context 'with matches' do
|
|
let(:variables) do
|
|
[{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAR' }]
|
|
end
|
|
|
|
it 'creates a pipeline' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('regular-job')
|
|
end
|
|
|
|
context 'when a job requires the same variable' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $SOME_VARIABLE
|
|
|
|
build:
|
|
stage: build
|
|
script: 'echo build'
|
|
rules:
|
|
- if: $SOME_VARIABLE
|
|
|
|
test1:
|
|
stage: test
|
|
script: 'echo test1'
|
|
needs: [build]
|
|
|
|
test2:
|
|
stage: test
|
|
script: 'echo test2'
|
|
EOY
|
|
end
|
|
|
|
it 'creates a pipeline' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('build', 'test1', 'test2')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'with no matches' do
|
|
let(:variables) { {} }
|
|
|
|
it 'does not create a pipeline', :aggregate_failures do
|
|
expect(response).to be_error
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
|
|
context 'when a job requires the same variable' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- if: $SOME_VARIABLE
|
|
|
|
build:
|
|
stage: build
|
|
script: 'echo build'
|
|
rules:
|
|
- if: $SOME_VARIABLE
|
|
|
|
test1:
|
|
stage: test
|
|
script: 'echo test1'
|
|
needs: [build]
|
|
|
|
test2:
|
|
stage: test
|
|
script: 'echo test2'
|
|
EOY
|
|
end
|
|
|
|
it 'does not create a pipeline', :aggregate_failures do
|
|
expect(response).to be_error
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'changes' do
|
|
shared_examples 'comparing file changes with workflow rules' do
|
|
context 'when matches' do
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[file1.md])
|
|
end
|
|
end
|
|
|
|
it 'creates the pipeline with a job' do
|
|
expect(pipeline).to be_persisted
|
|
expect(build_names).to contain_exactly('job')
|
|
end
|
|
end
|
|
|
|
context 'when does not match' do
|
|
before do
|
|
allow_next_instance_of(Ci::Pipeline) do |pipeline|
|
|
allow(pipeline).to receive(:modified_paths).and_return(%w[unknown])
|
|
end
|
|
end
|
|
|
|
it 'creates the pipeline with a job' do
|
|
expect(pipeline.errors.full_messages).to eq(['Pipeline filtered out by workflow rules.'])
|
|
expect(response).to be_error
|
|
expect(pipeline).not_to be_persisted
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'changes is an array' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- changes: [file1.md]
|
|
|
|
job:
|
|
script: exit 0
|
|
EOY
|
|
end
|
|
|
|
it_behaves_like 'comparing file changes with workflow rules'
|
|
end
|
|
|
|
context 'changes:paths is an array' do
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
rules:
|
|
- changes:
|
|
paths: [file1.md]
|
|
|
|
job:
|
|
script: exit 0
|
|
EOY
|
|
end
|
|
|
|
it_behaves_like 'comparing file changes with workflow rules'
|
|
end
|
|
end
|
|
|
|
context 'workflow name with rules' do
|
|
let(:ref) { 'refs/heads/feature' }
|
|
|
|
let(:variables) do
|
|
[{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAL' }]
|
|
end
|
|
|
|
let(:pipeline) do
|
|
execute_service do |pipeline|
|
|
pipeline.variables.build(variables)
|
|
end.payload
|
|
end
|
|
|
|
let(:config) do
|
|
<<-EOY
|
|
workflow:
|
|
name: '$PIPELINE_NAME $SOME_VARIABLE'
|
|
rules:
|
|
- if: $CI_COMMIT_REF_NAME =~ /master/
|
|
variables:
|
|
PIPELINE_NAME: 'Name 1'
|
|
- if: $CI_COMMIT_REF_NAME =~ /feature/
|
|
variables:
|
|
PIPELINE_NAME: 'Name 2'
|
|
|
|
job:
|
|
stage: test
|
|
script: echo 'hello'
|
|
EOY
|
|
end
|
|
|
|
it 'substitutes variables in pipeline name' do
|
|
expect(response).not_to be_error
|
|
expect(pipeline).to be_persisted
|
|
expect(pipeline.name).to eq('Name 2 SOME_VAL')
|
|
end
|
|
end
|
|
end
|
|
end
|