diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index 7340e90c5351b4d1e55ba780c627c0ae1418cd02..476f46a72c6e896de774bb1778bda2e507735ad5 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -31,7 +31,7 @@ def create_pipeline_from_trigger(trigger) response = Ci::CreatePipelineService .new(project, trigger.owner, ref: params[:ref], variables_attributes: variables) - .execute(:trigger, ignore_skip_ci: true) do |pipeline| + .execute(:trigger, ignore_skip_ci: true, inputs: inputs) do |pipeline| pipeline.trigger = trigger pipeline.trigger_requests.build(trigger: trigger, project_id: project.id) end @@ -62,7 +62,7 @@ def create_pipeline_from_job(job) response = Ci::CreatePipelineService .new(project, job.user, ref: params[:ref], variables_attributes: variables) - .execute(:pipeline, ignore_skip_ci: true) do |pipeline| + .execute(:pipeline, ignore_skip_ci: true, inputs: inputs) do |pipeline| source = job.sourced_pipelines.build( source_pipeline: job.pipeline, source_project: job.project, @@ -82,6 +82,10 @@ def job_from_token end end + def inputs + params[:inputs] + end + def variables param_variables + [payload_variable] end diff --git a/doc/api/pipeline_triggers.md b/doc/api/pipeline_triggers.md index 699a415f26b5d8cd4ccf047b2c94eda46da951c7..cf12a9d9b2b610e9e351790f21424db638b3a87d 100644 --- a/doc/api/pipeline_triggers.md +++ b/doc/api/pipeline_triggers.md @@ -156,6 +156,13 @@ curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://git ## Trigger a pipeline with a token +{{< history >}} + +- `inputs` attribute [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/519958) in GitLab 17.10 +[with a flag](../administration/feature_flags.md) named `ci_inputs_for_pipelines`. Disabled by default. + +{{< /history >}} + Trigger a pipeline by using a [pipeline trigger token](../ci/triggers/_index.md#create-a-pipeline-trigger-token) or a [CI/CD job token](../ci/jobs/ci_job_token.md) for authentication. @@ -177,11 +184,26 @@ Supported attributes: | `ref` | string | Yes | The branch or tag to run the pipeline on. | | `token` | string | Yes | The trigger token or CI/CD job token. | | `variables` | hash | No | A map of key-valued strings containing the pipeline variables. For example: `{ VAR1: "value1", VAR2: "value2" }`. | +| `inputs` | hash | No | A map of inputs, as key-value pairs, to use when creating the pipeline. Required feature flag: `ci_inputs_for_pipelines` | + +Example request with [variables](../ci/variables/_index.md): + +```shell +curl --request POST \ + --form "variables[VAR1]=value1" \ + --form "variables[VAR2]=value2" \ + "https://gitlab.example.com/api/v4/projects/123/trigger/pipeline?token=2cb1840fb9dfc9fb0b7b1609cd29cb&ref=main" +``` + +Example request with [inputs](../ci/yaml/inputs.md): -Example request: +_Required [feature flag](feature_flags.md): `ci_inputs_for_pipelines`_ ```shell -curl --request POST --form "variables[VAR1]=value1" --form "variables[VAR2]=value2" "https://gitlab.example.com/api/v4/projects/123/trigger/pipeline?token=2cb1840fb9dfc9fb0b7b1609cd29cb&ref=main" +curl --request POST \ + --header "Content-Type: application/json" \ + --data '{"inputs": {"environment": "environment", "scan_security": false, "level": 3}}' \ + "https://gitlab.example.com/api/v4/projects/123/trigger/pipeline?token=2cb1840fb9dfc9fb0b7b1609cd29cb&ref=main" ``` Example response: diff --git a/lib/api/ci/triggers.rb b/lib/api/ci/triggers.rb index f9afa754f6342e63de50c23dcbbe76c7ba2c9357..2b106e73c2eefb49354d1431d95754e5eb3578df 100644 --- a/lib/api/ci/triggers.rb +++ b/lib/api/ci/triggers.rb @@ -31,6 +31,7 @@ class Triggers < ::API::Base documentation: { example: '6d056f63e50fe6f8c5f8f4aa10edb7' } optional :variables, type: Hash, desc: 'The list of variables to be injected into build', documentation: { example: { VAR1: "value1", VAR2: "value2" } } + optional :inputs, type: Hash, desc: 'The list of inputs to be used to create the pipeline.' end post ":id/(ref/:ref/)trigger/pipeline", requirements: { ref: /.+/ } do Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/20758') diff --git a/lib/ci/pipeline_creation/inputs.rb b/lib/ci/pipeline_creation/inputs.rb new file mode 100644 index 0000000000000000000000000000000000000000..af1234de8ba8c9ca33d9a740a0ff956e72a22019 --- /dev/null +++ b/lib/ci/pipeline_creation/inputs.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ci + module PipelineCreation + module Inputs + def self.parse_params(params) + return params unless params.is_a?(Hash) + + params.to_hash.transform_values do |value| # `to_hash` to avoid `ActiveSupport::HashWithIndifferentAccess` + next value unless value.is_a?(String) + + begin + Gitlab::Json.parse(value) # convert to number, boolean, array + rescue JSON::ParserError + value # we treat the value as-is as it's likely a string like 'blue-green'. + end + end.deep_symbolize_keys # `deep_symbolize_keys` because Interpolator requires + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/config/content.rb b/lib/gitlab/ci/pipeline/chain/config/content.rb index cdd9a0825d35bc0315a0573c109d63c71aed986f..f7824fb9a89b9e1b8271ecc0db651870878771c6 100644 --- a/lib/gitlab/ci/pipeline/chain/config/content.rb +++ b/lib/gitlab/ci/pipeline/chain/config/content.rb @@ -35,7 +35,7 @@ def pipeline_config triggered_for_branch: @pipeline.branch?, ref: @pipeline.ref, pipeline_policy_context: @command.pipeline_policy_context, - inputs: @command.inputs + inputs: ::Ci::PipelineCreation::Inputs.parse_params(@command.inputs) ) end end diff --git a/lib/gitlab/ci/project_config/source.rb b/lib/gitlab/ci/project_config/source.rb index d355e879db34ae78e38265104c029bca99d13ba4..219980df32731790753b0946588278ad0b939628 100644 --- a/lib/gitlab/ci/project_config/source.rb +++ b/lib/gitlab/ci/project_config/source.rb @@ -61,7 +61,9 @@ def ci_yaml_include(config) end def include_inputs - { 'inputs' => inputs }.compact_blank + { inputs: inputs } + .compact_blank + .deep_stringify_keys # to avoid symbols in the YAML end end end diff --git a/spec/lib/ci/pipeline_creation/inputs_spec.rb b/spec/lib/ci/pipeline_creation/inputs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..703f105acae93aea245bd27ee4063fe0c0c560ea --- /dev/null +++ b/spec/lib/ci/pipeline_creation/inputs_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'oj' +require_relative Rails.root.join('lib/ci/pipeline_creation/inputs.rb') + +RSpec.describe Ci::PipelineCreation::Inputs, feature_category: :pipeline_composition do + describe '.parse_params' do + let(:params) do + { + 'string_param' => 'regular-string', + json_array: '[1, 2, 3]', + 'json_object' => '{"key": "value"}', + 'json_boolean' => 'true', + 'json_number' => '42', + 'nested' => { 'param' => 'value' } + } + end + + subject(:parse_params) { described_class.parse_params(params) } + + it 'transforms values' do + expect(parse_params).to include( + string_param: 'regular-string', + json_array: [1, 2, 3], + json_object: { key: 'value' }, + json_boolean: true, + json_number: 42, + nested: { param: 'value' } + ) + end + + context 'when params are not a hash' do + let(:params) { 'not a hash' } + + it 'returns the params as-is' do + expect(parse_params).to eq('not a hash') + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb index 09e8ca4561255abad794691a3a6707e6cb8bb2eb..be432859afbb8e46048a864950240d852d6ddef2 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb @@ -150,14 +150,26 @@ end context 'when passing inputs' do - let(:inputs) { { 'foo' => 'bar' } } + let(:inputs) do + { + 'string' => 'bar', + boolean: true, + 'array' => [{ 'foo' => 'bar' }], + number: 1 + } + end + let(:config_content_result) do <<~CICONFIG --- include: - local: ".gitlab-ci.yml" inputs: - foo: bar + string: bar + boolean: true + array: + - foo: bar + number: 1 CICONFIG end @@ -168,6 +180,7 @@ expect(pipeline.pipeline_config.content).to eq(config_content_result) expect(command.config_content).to eq(config_content_result) expect(command.pipeline_config.internal_include_prepended?).to eq(true) + expect(command.pipeline_config.inputs_for_pipeline_creation).to eq({}) end end end @@ -217,7 +230,15 @@ end context 'when passing inputs' do - let(:inputs) { { 'foo' => 'bar' } } + let(:content) do + <<~EOY + --- + stages: + - $[[ inputs.stage ]] + EOY + end + + let(:inputs) { { stage: 'bar' } } it 'uses the parameter content with inputs' do subject.perform! @@ -226,6 +247,7 @@ expect(pipeline.pipeline_config.content).to eq(content) expect(command.config_content).to eq(content) expect(command.pipeline_config.internal_include_prepended?).to eq(false) + expect(command.pipeline_config.inputs_for_pipeline_creation).to eq(inputs) end end end diff --git a/spec/requests/api/ci/triggers_spec.rb b/spec/requests/api/ci/triggers_spec.rb index 615d3b15c890665fa3ecb08c8fcc9eb5fe75d33c..330e271d3a16d0ca104d5f3f42a15bc0f50e7a15 100644 --- a/spec/requests/api/ci/triggers_spec.rb +++ b/spec/requests/api/ci/triggers_spec.rb @@ -181,6 +181,84 @@ expect(response).to have_gitlab_http_status(:forbidden) end end + + context 'when using inputs' do + let(:inputs) do + { + deploy_strategy: 'blue-green', + job_stage: 'deploy', + test_script: ['echo "test"'], + parallel_jobs: 3, + allow_failure: true, + test_rules: [ + { if: '$CI_PIPELINE_SOURCE == "web"' } + ] + } + end + + before do + stub_ci_pipeline_yaml_file( + File.read(Rails.root.join('spec/lib/gitlab/ci/config/yaml/fixtures/complex-included-ci.yml')) + ) + end + + shared_examples 'sending request using inputs' do + shared_examples 'creating a succesful pipeline' do + it 'creates a pipeline using inputs' do + expect { post_request }.to change { Ci::Pipeline.count }.by(1) + + expect(response).to have_gitlab_http_status(:created) + + pipeline = Ci::Pipeline.find(json_response['id']) + + expect(pipeline.builds.map { |b| "#{b.name} #{b.allow_failure}" }).to contain_exactly( + 'my-job-build 1/3 false', 'my-job-build 2/3 false', 'my-job-build 3/3 false', + 'my-job-test true', 'my-job-deploy false' + ) + end + end + + context 'when passing parameters as JSON' do + let(:headers) do + { 'Content-Type' => 'application/json' } + end + + subject(:post_request) do + post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{token}"), + headers: headers, + params: { ref: 'refs/heads/other-branch', inputs: inputs }.to_json + end + + it_behaves_like 'creating a succesful pipeline' + end + + context 'when passing parameters as form data' do + let(:headers) do + { 'Content-Type' => 'application/x-www-form-urlencoded' } + end + + subject(:post_request) do + post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{token}"), + headers: headers, + params: { ref: 'refs/heads/other-branch', inputs: inputs.transform_values(&:to_json) } + end + + it_behaves_like 'creating a succesful pipeline' + end + end + + context 'when triggering a pipeline from a trigger token' do + let!(:token) { trigger_token } + + it_behaves_like 'sending request using inputs' + end + + context 'when triggered from another running job' do + let!(:token) { create(:ci_build, :running, project: project, user: user).token } + + it_behaves_like 'sending request using inputs' + end + end end describe 'GET /projects/:id/triggers' do diff --git a/spec/services/ci/pipeline_trigger_service_spec.rb b/spec/services/ci/pipeline_trigger_service_spec.rb index 68f0ca5efeeeddd810b1200e998ab1c8bf03969a..d459a9968aa3f2e3d0560b6436cb4dc0744a2c6e 100644 --- a/spec/services/ci/pipeline_trigger_service_spec.rb +++ b/spec/services/ci/pipeline_trigger_service_spec.rb @@ -45,6 +45,33 @@ end end + shared_examples 'accepting inputs' do + let(:inputs) do + { + deploy_strategy: 'blue-green', + job_stage: 'deploy', + test_script: ['echo "test"'], + test_rules: [ + { if: '$CI_PIPELINE_SOURCE == "web"' } + ] + } + end + + before do + stub_ci_pipeline_yaml_file( + File.read(Rails.root.join('spec/lib/gitlab/ci/config/yaml/fixtures/complex-included-ci.yml')) + ) + end + + it 'triggers a pipeline using inputs' do + expect { result }.to change { Ci::Pipeline.count }.by(1) + + expect(result.payload[:pipeline].builds.map(&:name)).to contain_exactly( + 'my-job-build 1/2', 'my-job-build 2/2', 'my-job-test', 'my-job-deploy' + ) + end + end + context 'with a trigger token' do let(:trigger) { create(:ci_trigger, project: project, owner: user) } @@ -150,6 +177,12 @@ expect(result).to be_nil end end + + context 'when using inputs' do + let(:params) { { token: trigger.token, ref: 'master', inputs: inputs } } + + it_behaves_like 'accepting inputs' + end end context 'with a pipeline job token' do @@ -253,6 +286,12 @@ expect(result).to be_nil end end + + context 'when using inputs' do + let(:params) { { token: job.token, ref: 'master', inputs: inputs } } + + it_behaves_like 'accepting inputs' + end end end end