diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 9f4b8462f35cd9fef77dce8ff3220b0fa5d75f81..a74c9dcee9cd0384f5fa2871dfac2d8089ac1418 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -138,6 +138,7 @@ class Build < Ci::Processable accepts_nested_attributes_for :runner_session, update_only: true accepts_nested_attributes_for :job_variables + accepts_nested_attributes_for :inputs delegate :url, to: :runner_session, prefix: true, allow_nil: true delegate :terminal_specification, to: :runner_session, allow_nil: true diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index b2bb73a2c348af32469cff14589b606a1da22488..1932568a41d1ccad3861ccdafb17c8efe85f58b8 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -52,7 +52,7 @@ def runner_inputs input_value = input_values[name.to_s]&.value || spec[:default] { - key: name, + key: name.to_s, value: { content: input_value, type: spec[:input_type] diff --git a/lib/gitlab/ci/build/inputs.rb b/lib/gitlab/ci/build/inputs.rb new file mode 100644 index 0000000000000000000000000000000000000000..f331ee8a389bc245497532d3bc759003221e9671 --- /dev/null +++ b/lib/gitlab/ci/build/inputs.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Build + class Inputs + Result = Struct.new(:inputs_attributes, keyword_init: true) do + def build_attributes + inputs_attributes + end + end + + def initialize(inputs_spec) + @inputs_spec = inputs_spec || {} + end + + def evaluate(pipeline) + return Result.new if @inputs_spec.empty? + + inputs_attributes = @inputs_spec.filter_map do |name, spec| + { + project: pipeline.project, + name: name.to_s, + value: spec[:default] + } + end + + Result.new(inputs_attributes: inputs_attributes) + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 5a402b869022d7f58ed02cf2d97032f08e36b36a..ebd6717b65e48f37b66490589a6548fb2b3e92d4 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -13,7 +13,7 @@ class Job < ::Gitlab::Config::Entry::Node ALLOWED_WHEN = %w[on_success on_failure always manual delayed].freeze ALLOWED_KEYS = %i[tags script image services start_in artifacts cache dependencies before_script after_script hooks - coverage retry parallel timeout + coverage retry parallel timeout inputs release id_tokens publish pages manual_confirmation run].freeze PUBLIC_DIR = 'public' @@ -61,6 +61,19 @@ class Job < ::Gitlab::Config::Entry::Node validates :publish, absence: { message: "can only be used within a `pages` job" }, unless: -> { config.is_a?(Hash) && pages_job? } + + validate do + if inputs_defined? + inputs_spec = inputs_value || {} + spec_inputs = ::Ci::PipelineCreation::Inputs::SpecInputs.new(inputs_spec) + + spec_inputs.validate_input_params!({}) + + spec_inputs.errors.each do |error| + errors.add(:inputs, error) + end + end + end end entry :before_script, Entry::Commands, @@ -141,10 +154,15 @@ class Job < ::Gitlab::Config::Entry::Node inherit: false, description: 'Pages configuration.' + entry :inputs, ::Gitlab::Config::Entry::ComposableHash, + description: 'Job input parameters.', + inherit: false, + metadata: { composable_class: ::Gitlab::Ci::Config::Entry::JobInput } + attributes :script, :tags, :when, :dependencies, :needs, :retry, :parallel, :start_in, :timeout, :release, - :allow_failure, :publish, :pages, :manual_confirmation, :run + :allow_failure, :publish, :pages, :manual_confirmation, :run, :inputs def self.matching?(name, config) !name.to_s.start_with?('.') && config.is_a?(Hash) && (config.key?(:script) || config.key?(:run)) @@ -185,7 +203,8 @@ def value publish: publish, pages: pages, manual_confirmation: self.manual_confirmation, - run: run + run: run, + inputs: inputs_value ).compact end diff --git a/lib/gitlab/ci/config/entry/job_input.rb b/lib/gitlab/ci/config/entry/job_input.rb new file mode 100644 index 0000000000000000000000000000000000000000..fe0e964cb3f39913a16061bf613d2fc4a0032e52 --- /dev/null +++ b/lib/gitlab/ci/config/entry/job_input.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + class JobInput < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[default description options regex type].freeze + ALLOWED_OPTIONS_LIMIT = 50 + + attributes ALLOWED_KEYS, prefix: :input + + validations do + validates :config, type: Hash, allowed_keys: ALLOWED_KEYS + validates :key, alphanumeric: true + validates :input_description, alphanumeric: true, allow_nil: true + validates :input_regex, type: String, allow_nil: true + validates :input_type, allow_nil: true, + allowed_values: ::Ci::PipelineCreation::Inputs::SpecInputs.input_types + validates :input_options, type: Array, allow_nil: true + + validate do + if input_options&.size.to_i > ALLOWED_OPTIONS_LIMIT + errors.add(:config, "cannot define more than #{ALLOWED_OPTIONS_LIMIT} options") + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index c47fbebca5fbed4c69c98543e3644f1d81c96b83..1c7bceff63a06fff45d8f9887cef823e6597a07e 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -30,6 +30,8 @@ def initialize(context, attributes, stages_for_needs_lookup) .fabricate(attributes.delete(:except)) @rules = Gitlab::Ci::Build::Rules .new(attributes.delete(:rules), default_when: attributes[:when]) + @inputs = Gitlab::Ci::Build::Inputs + .new(attributes[:inputs]) @cache = Gitlab::Ci::Build::Cache .new(attributes.delete(:cache), @pipeline) @@ -73,6 +75,7 @@ def attributes .deep_merge(runner_tags) .deep_merge(build_execution_config_attribute) .deep_merge(scoped_user_id_attribute) + .deep_merge(inputs_attributes) .except(:stage) end @@ -224,6 +227,24 @@ def allow_failure_criteria_attributes { options: { allow_failure_criteria: nil } } end + def inputs_attributes + return {} unless @seed_attributes.key?(:inputs) + + { + inputs_attributes: @inputs.evaluate(@pipeline).build_attributes, + options: { inputs: inputs_config } + } + end + strong_memoize_attr :inputs_attributes + + def inputs_config + @seed_attributes.delete(:inputs).transform_values do |input| + input_type = input.delete(:type) || 'string' + + input.merge(input_type: input_type) + end + end + def calculate_yaml_variables! @seed_attributes[:yaml_variables] = Gitlab::Ci::Variables::Helpers.inherit_yaml_variables( from: @context.root_variables, to: @job_variables, inheritance: @root_variables_inheritance diff --git a/lib/gitlab/ci/yaml_processor/result.rb b/lib/gitlab/ci/yaml_processor/result.rb index b52ef560e5325a16b0bbc10d03888061714a6494..eeaa9884a6885ef667aec8323adba7cbc36488b5 100644 --- a/lib/gitlab/ci/yaml_processor/result.rb +++ b/lib/gitlab/ci/yaml_processor/result.rb @@ -109,6 +109,7 @@ def build_attributes(name) when: job[:when] || 'on_success', environment: job[:environment_name], coverage_regex: job[:coverage], + inputs: job[:inputs], # yaml_variables is calculated with using job_variables in Seed::Build job_variables: transform_to_array(job[:job_variables]), root_variables_inheritance: job[:root_variables_inheritance], diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index 4e23303c9322b388b8022cdf022ae3b24564d7da..b18fbe79f971c742968f8b25941c2450d03d8c2f 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -623,6 +623,30 @@ it { expect(entry).not_to be_valid } end end + + context 'when inputs are used' do + context 'with invalid default value type' do + let(:config) do + { + script: 'echo', + inputs: { + number_input: { + type: 'number', + default: 'not_a_number' + } + } + } + end + + it 'is not valid' do + expect(entry).not_to be_valid + end + + it 'reports error about invalid default value' do + expect(entry.errors.first).to include('is not a number') + end + end + end end context 'when only: is used with rules:' do diff --git a/spec/services/ci/create_pipeline_service/job_inputs_spec.rb b/spec/services/ci/create_pipeline_service/job_inputs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d2fdd8393836aa2838a2846a3dc24dbf08b5d2b9 --- /dev/null +++ b/spec/services/ci/create_pipeline_service/job_inputs_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::CreatePipelineService, + feature_category: :pipeline_composition do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.first_owner } + + let(:service) { described_class.new(project, user, { ref: 'master' }) } + let(:pipeline) { service.execute(:push).payload } + + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'when job has inputs with default values' do + let(:config) do + <<-YAML + rspec: + script: echo + inputs: + website: + type: string + default: "example.com" + debug: + type: boolean + default: false + YAML + end + + it 'creates the pipeline with inputs configuration and job input records' do + expect(pipeline).to be_created_successfully + + rspec_job = pipeline.processables.find { |job| job.name == 'rspec' } + + expect(rspec_job.options[:inputs]).to eq( + website: { + input_type: 'string', + default: 'example.com' + }, + debug: { + input_type: 'boolean', + default: false + } + ) + + inputs = rspec_job.inputs.order(:name) + expect(inputs.map(&:name)).to eq(%w[debug website]) + expect(inputs.map(&:value)).to eq([false, 'example.com']) + + job_for_runner = Ci::BuildRunnerPresenter.new(rspec_job) + expect(job_for_runner.runner_inputs).to contain_exactly( + { + key: 'website', + value: { + content: 'example.com', + type: 'string' + } + }, + { + key: 'debug', + value: { + content: false, + type: 'boolean' + } + } + ) + end + end +end