From 38f1be6387f1efbf4aca15ff70116f602ad740f3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 17 Feb 2023 10:46:41 +0100 Subject: [PATCH 01/15] Add scaffold of CI component context, spec and args --- lib/gitlab/ci/components/header.rb | 45 +++++++++++++++++++ lib/gitlab/ci/components/input/arguments.rb | 25 +++++++++++ .../ci/components/input/specification.rb | 41 +++++++++++++++++ spec/lib/gitlab/ci/components/header_spec.rb | 26 +++++++++++ .../ci/components/input/arguments_spec.rb | 15 +++++++ .../ci/components/input/specification_spec.rb | 11 +++++ 6 files changed, 163 insertions(+) create mode 100644 lib/gitlab/ci/components/header.rb create mode 100644 lib/gitlab/ci/components/input/arguments.rb create mode 100644 lib/gitlab/ci/components/input/specification.rb create mode 100644 spec/lib/gitlab/ci/components/header_spec.rb create mode 100644 spec/lib/gitlab/ci/components/input/arguments_spec.rb create mode 100644 spec/lib/gitlab/ci/components/input/specification_spec.rb diff --git a/lib/gitlab/ci/components/header.rb b/lib/gitlab/ci/components/header.rb new file mode 100644 index 00000000000000..8aa028891fa12b --- /dev/null +++ b/lib/gitlab/ci/components/header.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + ## + # Components::Header class represents full component specification that is being prepended as first YAML document + # in the CI Component file. + # + class Header + def initialize(header) + @header = header + end + + def errors + spec.errors + end + + def valid? + errors.none? + end + + def spec + @spec ||= Components::Input::Specification + .new(spec_inputs_hash) + end + + def to_args(user_inputs) + @args ||= Components::Input::Arguments + .new(spec, user_inputs) + end + + def version + raise NotImplementedError + end + + private + + def spec_inputs_hash + @header.dig(:spec, :inputs) + end + end + end + end +end diff --git a/lib/gitlab/ci/components/input/arguments.rb b/lib/gitlab/ci/components/input/arguments.rb new file mode 100644 index 00000000000000..abe3190929700d --- /dev/null +++ b/lib/gitlab/ci/components/input/arguments.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + module Input + ## + # Components::Input::Arguments class represents user-provided inputs, configured using `with:` keyword. + # + # Input arguments are only valid with an associated component's inputs specification from component's header. + # + class Arguments + def initialize(spec, args) + @spec = spec + @args = args + end + + def valid? + @spec.valid? # TODO + end + end + end + end + end +end diff --git a/lib/gitlab/ci/components/input/specification.rb b/lib/gitlab/ci/components/input/specification.rb new file mode 100644 index 00000000000000..783b2365a64a7b --- /dev/null +++ b/lib/gitlab/ci/components/input/specification.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + module Input + ## + # Components::Input class represents user-provided inputs, configured using `with:` keyword. + # + # Inputs are only valid with an associated component's header. + # + class Specification + include Gitlab::Utils::StrongMemoize + + attr_reader :errors + + def initialize(spec) + @spec = spec + @errors = [] + + validate! + end + + def valid? + @errors.none? + end + + def empty? + @spec.to_h.empty? + end + + private + + def validate! + @errors.push('component specification is empty') if empty? + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/components/header_spec.rb b/spec/lib/gitlab/ci/components/header_spec.rb new file mode 100644 index 00000000000000..3164d25e9fa83c --- /dev/null +++ b/spec/lib/gitlab/ci/components/header_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Header, feature_category: :pipeline_authoring do + subject { described_class.new(spec) } + + context 'when spec is valid' do + let(:spec) do + { + spec: { + inputs: { + website: nil, + run: { + options: %w[opt1 opt2] + } + } + } + } + end + + it 'fabricates a spec from valid data' do + expect(subject).to be_valid + end + end +end diff --git a/spec/lib/gitlab/ci/components/input/arguments_spec.rb b/spec/lib/gitlab/ci/components/input/arguments_spec.rb new file mode 100644 index 00000000000000..0f07e1e6bbc347 --- /dev/null +++ b/spec/lib/gitlab/ci/components/input/arguments_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Input::Arguments, feature_category: :pipeline_authoring do + let(:specification_class) { ::Gitlab::Ci::Components::Input::Specification } + + it 'describes user-provided inputs' do + spec = specification_class.new({ webiste: nil }) + + args = described_class.new(spec, { website: 'http://example.gitlab.com' }) + + expect(args).to be_valid + end +end diff --git a/spec/lib/gitlab/ci/components/input/specification_spec.rb b/spec/lib/gitlab/ci/components/input/specification_spec.rb new file mode 100644 index 00000000000000..be661e2839a7df --- /dev/null +++ b/spec/lib/gitlab/ci/components/input/specification_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Input::Specification, feature_category: :pipeline_authoring do + it 'describes component inputs specification' do + spec = described_class.new({ website: nil }) + + expect(spec).to be_valid + end +end -- GitLab From 5cf37fb012f91906e6c81341dfcacb8d508adba5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 17 Feb 2023 11:24:53 +0100 Subject: [PATCH 02/15] Add scaffold of CI component input arguments classes --- lib/gitlab/ci/components/input/args/base.rb | 52 +++++++++++++++++++ .../ci/components/input/args/default.rb | 24 +++++++++ .../ci/components/input/args/options.rb | 25 +++++++++ .../ci/components/input/args/required.rb | 24 +++++++++ lib/gitlab/ci/components/input/arguments.rb | 2 + 5 files changed, 127 insertions(+) create mode 100644 lib/gitlab/ci/components/input/args/base.rb create mode 100644 lib/gitlab/ci/components/input/args/default.rb create mode 100644 lib/gitlab/ci/components/input/args/options.rb create mode 100644 lib/gitlab/ci/components/input/args/required.rb diff --git a/lib/gitlab/ci/components/input/args/base.rb b/lib/gitlab/ci/components/input/args/base.rb new file mode 100644 index 00000000000000..24037ac083d849 --- /dev/null +++ b/lib/gitlab/ci/components/input/args/base.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + module Input + module Args + ## + # Components::Input::Args::Base is a common abstraction for input arguments: + # - required + # - optional + # - with a default value + # + class Base + attr_reader :errors + + ArgumentNotValidError = Class.new(StandardError) + + def initialize(key, spec, value) + @key = key # hash key / argument name + @value = value # user-provided value + @spec = spec # configured specification + @errors = [] + + validate! + end + + def value + raise NotImplementedError + end + + def validate! + raise NotImplementedError + end + + def to_hash + raise ArgumentNotValidError unless valid? + + @output ||= { key => value } + end + + private + + def error(message) + @errors.push("`#{@key}` input: #{message}") + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/components/input/args/default.rb b/lib/gitlab/ci/components/input/args/default.rb new file mode 100644 index 00000000000000..761c10e4119344 --- /dev/null +++ b/lib/gitlab/ci/components/input/args/default.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + module Input + module Args + ## + # Components::Input::Args::Default class represents user-provided input argument that has a default value. + # + class Default < Input::Args::Base + def validate! + raise NotImplementedError + end + + def value + raise NotImplementedError + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/components/input/args/options.rb b/lib/gitlab/ci/components/input/args/options.rb new file mode 100644 index 00000000000000..addfc823c4e382 --- /dev/null +++ b/lib/gitlab/ci/components/input/args/options.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + module Input + module Args + ## + # Components::Input::Args::Options class represents user-provided input argument that is an enum, and is only + # valid when the value provided is listed as an acceptable one. + # + class Options < Input::Args::Base + def validate! + raise NotImplementedError + end + + def value + raise NotImplementedError + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/components/input/args/required.rb b/lib/gitlab/ci/components/input/args/required.rb new file mode 100644 index 00000000000000..b542be23dc5d66 --- /dev/null +++ b/lib/gitlab/ci/components/input/args/required.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Components + module Input + module Args + ## + # Components::Input::Args::Required class represents user-provided required input argument. + # + class Required < Input::Args::Base + def validate! + raise NotImplementedError + end + + def value + raise NotImplementedError + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/components/input/arguments.rb b/lib/gitlab/ci/components/input/arguments.rb index abe3190929700d..1ed1522070d3b7 100644 --- a/lib/gitlab/ci/components/input/arguments.rb +++ b/lib/gitlab/ci/components/input/arguments.rb @@ -10,6 +10,8 @@ module Input # Input arguments are only valid with an associated component's inputs specification from component's header. # class Arguments + include Enumerable + def initialize(spec, args) @spec = spec @args = args -- GitLab From 1acce456b550becc1fc555998aa3746ceef5de9f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 17 Feb 2023 12:07:25 +0100 Subject: [PATCH 03/15] Implement CI components input argument strategies --- lib/gitlab/ci/components/input/args/base.rb | 12 ++++-- .../ci/components/input/args/default.rb | 10 +++-- .../ci/components/input/args/options.rb | 12 ++++-- .../ci/components/input/args/required.rb | 6 +-- lib/gitlab/ci/components/input/arguments.rb | 4 ++ .../ci/components/input/args/default_spec.rb | 38 +++++++++++++++++++ .../ci/components/input/args/options_spec.rb | 31 +++++++++++++++ .../ci/components/input/args/required_spec.rb | 25 ++++++++++++ 8 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 spec/lib/gitlab/ci/components/input/args/default_spec.rb create mode 100644 spec/lib/gitlab/ci/components/input/args/options_spec.rb create mode 100644 spec/lib/gitlab/ci/components/input/args/required_spec.rb diff --git a/lib/gitlab/ci/components/input/args/base.rb b/lib/gitlab/ci/components/input/args/base.rb index 24037ac083d849..7b683d11ca9bed 100644 --- a/lib/gitlab/ci/components/input/args/base.rb +++ b/lib/gitlab/ci/components/input/args/base.rb @@ -12,7 +12,7 @@ module Args # - with a default value # class Base - attr_reader :errors + attr_reader :key, :value, :spec, :errors ArgumentNotValidError = Class.new(StandardError) @@ -25,18 +25,22 @@ def initialize(key, spec, value) validate! end - def value - raise NotImplementedError + def valid? + @errors.none? end def validate! raise NotImplementedError end + def to_value + raise NotImplementedError + end + def to_hash raise ArgumentNotValidError unless valid? - @output ||= { key => value } + @output ||= { key => to_value } end private diff --git a/lib/gitlab/ci/components/input/args/default.rb b/lib/gitlab/ci/components/input/args/default.rb index 761c10e4119344..bb3a1c1f3d8b39 100644 --- a/lib/gitlab/ci/components/input/args/default.rb +++ b/lib/gitlab/ci/components/input/args/default.rb @@ -10,11 +10,15 @@ module Args # class Default < Input::Args::Base def validate! - raise NotImplementedError + error('invalid specification') unless default.present? end - def value - raise NotImplementedError + def to_value + value.to_s.empty? ? default : value + end + + def default + spec[:default] end end end diff --git a/lib/gitlab/ci/components/input/args/options.rb b/lib/gitlab/ci/components/input/args/options.rb index addfc823c4e382..99c59aefcd9c78 100644 --- a/lib/gitlab/ci/components/input/args/options.rb +++ b/lib/gitlab/ci/components/input/args/options.rb @@ -11,11 +11,17 @@ module Args # class Options < Input::Args::Base def validate! - raise NotImplementedError + return error('argument specification invalid') if options.to_a.empty? + + error('argument value not allowed') unless options.include?(value) + end + + def to_value + value end - def value - raise NotImplementedError + def options + spec[:options] end end end diff --git a/lib/gitlab/ci/components/input/args/required.rb b/lib/gitlab/ci/components/input/args/required.rb index b542be23dc5d66..fd015529240a95 100644 --- a/lib/gitlab/ci/components/input/args/required.rb +++ b/lib/gitlab/ci/components/input/args/required.rb @@ -10,11 +10,11 @@ module Args # class Required < Input::Args::Base def validate! - raise NotImplementedError + error('required value has not been provided') if value.to_s.empty? end - def value - raise NotImplementedError + def to_value + value end end end diff --git a/lib/gitlab/ci/components/input/arguments.rb b/lib/gitlab/ci/components/input/arguments.rb index 1ed1522070d3b7..8cc6c6b638d412 100644 --- a/lib/gitlab/ci/components/input/arguments.rb +++ b/lib/gitlab/ci/components/input/arguments.rb @@ -17,6 +17,10 @@ def initialize(spec, args) @args = args end + def each + # TODO: only strings and empty values are allowed for now + end + def valid? @spec.valid? # TODO end diff --git a/spec/lib/gitlab/ci/components/input/args/default_spec.rb b/spec/lib/gitlab/ci/components/input/args/default_spec.rb new file mode 100644 index 00000000000000..d13d6e265f294f --- /dev/null +++ b/spec/lib/gitlab/ci/components/input/args/default_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Input::Args::Default, feature_category: :pipeline_authoring do + it 'returns a user-provided value if it is present' do + argument = described_class.new(:website, { default: 'https://gitlab.com' }, 'https://example.gitlab.com') + + expect(argument).to be_valid + expect(argument.to_value).to eq 'https://example.gitlab.com' + expect(argument.to_hash).to eq({ website: 'https://example.gitlab.com' }) + end + + ## + # TODO: how it should behaving in this case? Should we use a default value or an empty one? + # + it 'returns a default value if user-provider one is empty' do + argument = described_class.new(:website, { default: 'https://gitlab.com' }, '') + + expect(argument).to be_valid + expect(argument.to_value).to eq 'https://gitlab.com' + expect(argument.to_hash).to eq({ website: 'https://gitlab.com' }) + end + + it 'returns a default value if user-provider one is unknown' do + argument = described_class.new(:website, { default: 'https://gitlab.com' }, nil) + + expect(argument).to be_valid + expect(argument.to_value).to eq 'https://gitlab.com' + expect(argument.to_hash).to eq({ website: 'https://gitlab.com' }) + end + + it 'returns an error if the argument has not been fabricated correctly' do + argument = described_class.new(:website, { required: 'https://gitlab.com' }, 'https://example.gitlab.com') + + expect(argument).not_to be_valid + end +end diff --git a/spec/lib/gitlab/ci/components/input/args/options_spec.rb b/spec/lib/gitlab/ci/components/input/args/options_spec.rb new file mode 100644 index 00000000000000..1412611aec49f6 --- /dev/null +++ b/spec/lib/gitlab/ci/components/input/args/options_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Input::Args::Options, feature_category: :pipeline_authoring do + it 'returns a user-provided value if it is an allowed one' do + argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt1') + + expect(argument).to be_valid + expect(argument.to_value).to eq 'opt1' + expect(argument.to_hash).to eq({ run: 'opt1' }) + end + + it 'returns an error if user-provided value is not allowlisted' do + argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt3') + + expect(argument).not_to be_valid + end + + it 'returns an error if specification is not correct' do + argument = described_class.new(:website, { options: nil }, 'opt1') + + expect(argument).not_to be_valid + end + + it 'returns an error if specification is using a hash' do + argument = described_class.new(:website, { options: { a: 1 } }, 'opt1') + + expect(argument).not_to be_valid + end +end diff --git a/spec/lib/gitlab/ci/components/input/args/required_spec.rb b/spec/lib/gitlab/ci/components/input/args/required_spec.rb new file mode 100644 index 00000000000000..022648d15bb1f4 --- /dev/null +++ b/spec/lib/gitlab/ci/components/input/args/required_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Input::Args::Required, feature_category: :pipeline_authoring do + it 'returns a user-provided value if it is present' do + argument = described_class.new(:website, nil, 'https://example.gitlab.com') + + expect(argument).to be_valid + expect(argument.to_value).to eq 'https://example.gitlab.com' + expect(argument.to_hash).to eq({ website: 'https://example.gitlab.com' }) + end + + it 'returns an error if user-provided value is empty' do + argument = described_class.new(:website, nil, '') + + expect(argument).not_to be_valid + end + + it 'returns an error if user-provided value is not specified' do + argument = described_class.new(:website, nil, nil) + + expect(argument).not_to be_valid + end +end -- GitLab From 3ee67e2f49dcaa45047f6b5a47d024cfaae22b26 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 17 Feb 2023 13:25:18 +0100 Subject: [PATCH 04/15] Implement Ci components input arguments matching --- lib/gitlab/ci/components/header.rb | 6 +-- lib/gitlab/ci/components/input/args/base.rb | 8 ++++ .../ci/components/input/args/default.rb | 4 ++ .../ci/components/input/args/options.rb | 4 ++ .../ci/components/input/args/required.rb | 7 ++++ lib/gitlab/ci/components/input/arguments.rb | 39 +++++++++++++++++-- .../ci/components/input/specification.rb | 8 ++++ .../ci/components/input/args/base_spec.rb | 19 +++++++++ .../ci/components/input/args/default_spec.rb | 10 +++++ .../ci/components/input/args/options_spec.rb | 10 +++++ .../ci/components/input/args/required_spec.rb | 10 +++++ .../ci/components/input/arguments_spec.rb | 23 ++++++++++- 12 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 spec/lib/gitlab/ci/components/input/args/base_spec.rb diff --git a/lib/gitlab/ci/components/header.rb b/lib/gitlab/ci/components/header.rb index 8aa028891fa12b..57d06fabfba30a 100644 --- a/lib/gitlab/ci/components/header.rb +++ b/lib/gitlab/ci/components/header.rb @@ -17,7 +17,7 @@ def errors end def valid? - errors.none? + errors.none? # TODO end def spec @@ -25,9 +25,9 @@ def spec .new(spec_inputs_hash) end - def to_args(user_inputs) + def to_args(inputs) @args ||= Components::Input::Arguments - .new(spec, user_inputs) + .new(spec, inputs) end def version diff --git a/lib/gitlab/ci/components/input/args/base.rb b/lib/gitlab/ci/components/input/args/base.rb index 7b683d11ca9bed..70ba4750f93550 100644 --- a/lib/gitlab/ci/components/input/args/base.rb +++ b/lib/gitlab/ci/components/input/args/base.rb @@ -22,6 +22,10 @@ def initialize(key, spec, value) @spec = spec # configured specification @errors = [] + unless value.is_a?(String) || value.nil? # rubocop:disable Style/IfUnlessModifier + @errors.push("unsupported value in input argument `#{key}`") + end + validate! end @@ -43,6 +47,10 @@ def to_hash @output ||= { key => to_value } end + def self.matches?(spec) + raise NotImplementedError + end + private def error(message) diff --git a/lib/gitlab/ci/components/input/args/default.rb b/lib/gitlab/ci/components/input/args/default.rb index bb3a1c1f3d8b39..be8d8567afa406 100644 --- a/lib/gitlab/ci/components/input/args/default.rb +++ b/lib/gitlab/ci/components/input/args/default.rb @@ -20,6 +20,10 @@ def to_value def default spec[:default] end + + def self.matches?(spec) + spec.count == 1 && spec.each_key.first == :default + end end end end diff --git a/lib/gitlab/ci/components/input/args/options.rb b/lib/gitlab/ci/components/input/args/options.rb index 99c59aefcd9c78..ba57729fa3ae6e 100644 --- a/lib/gitlab/ci/components/input/args/options.rb +++ b/lib/gitlab/ci/components/input/args/options.rb @@ -23,6 +23,10 @@ def to_value def options spec[:options] end + + def self.matches?(spec) + spec.count == 1 && spec.each_key.first == :options + end end end end diff --git a/lib/gitlab/ci/components/input/args/required.rb b/lib/gitlab/ci/components/input/args/required.rb index fd015529240a95..f6a315cdf9b4fe 100644 --- a/lib/gitlab/ci/components/input/args/required.rb +++ b/lib/gitlab/ci/components/input/args/required.rb @@ -16,6 +16,13 @@ def validate! def to_value value end + + ## + # TODO how to handle empty string here? + # + def self.matches?(spec) + spec.nil? + end end end end diff --git a/lib/gitlab/ci/components/input/arguments.rb b/lib/gitlab/ci/components/input/arguments.rb index 8cc6c6b638d412..bd4a381671825d 100644 --- a/lib/gitlab/ci/components/input/arguments.rb +++ b/lib/gitlab/ci/components/input/arguments.rb @@ -12,17 +12,50 @@ module Input class Arguments include Enumerable + UnknownSpecArgumentError = Class.new(StandardError) + + ARGUMENTS = [ + Input::Args::Required, # Input argument is required + Input::Args::Default, # Input argument has a default value + Input::Args::Options # Input argument that needs to be allowlisted + ].freeze + def initialize(spec, args) @spec = spec @args = args + @inputs = [] + @errors = [] + + validate! + fabricate! end - def each - # TODO: only strings and empty values are allowed for now + def each(&block) + @inputs.each(&block) end def valid? - @spec.valid? # TODO + @errors.none? && @spec.valid? # TODO @inputs + end + + def unknown + @args.keys - @spec.keys + end + + private + + def validate! + @errors.push("unspecified input arguments: #{unknown.inspect}") if unknown.any? + end + + def fabricate! + @spec.each do |key, spec| + argument = ARGUMENTS.find { |klass| klass.matches?(spec) } + + raise UnknownSpecArgumentError if argument.nil? + + @inputs.push(argument.new(key, spec, @args[key])) + end end end end diff --git a/lib/gitlab/ci/components/input/specification.rb b/lib/gitlab/ci/components/input/specification.rb index 783b2365a64a7b..b56202ddbd40b9 100644 --- a/lib/gitlab/ci/components/input/specification.rb +++ b/lib/gitlab/ci/components/input/specification.rb @@ -29,6 +29,14 @@ def empty? @spec.to_h.empty? end + def each(&block) + @spec.each_pair(&block) + end + + def keys + @spec.keys + end + private def validate! diff --git a/spec/lib/gitlab/ci/components/input/args/base_spec.rb b/spec/lib/gitlab/ci/components/input/args/base_spec.rb new file mode 100644 index 00000000000000..237404f8619cfc --- /dev/null +++ b/spec/lib/gitlab/ci/components/input/args/base_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Input::Args::Base, feature_category: :pipeline_authoring do + subject do + Class.new(described_class) do + def validate!; end + def to_value; end + end + end + + it 'fabricates an invalid input argument if unknown value is provided' do + argument = subject.new(:something, { spec: 123 }, [:a, :b]) + + expect(argument).not_to be_valid + expect(argument.errors.first).to eq 'unsupported value in input argument `something`' + end +end diff --git a/spec/lib/gitlab/ci/components/input/args/default_spec.rb b/spec/lib/gitlab/ci/components/input/args/default_spec.rb index d13d6e265f294f..72cb9639939c0b 100644 --- a/spec/lib/gitlab/ci/components/input/args/default_spec.rb +++ b/spec/lib/gitlab/ci/components/input/args/default_spec.rb @@ -35,4 +35,14 @@ expect(argument).not_to be_valid end + + describe '.matches?' do + it 'matches specs with default configuration' do + expect(described_class.matches?({ default: 'abc' })).to be true + end + + it 'does not match specs different configuration keyword' do + expect(described_class.matches?({ options: %w[a b] })).to be false + end + end end diff --git a/spec/lib/gitlab/ci/components/input/args/options_spec.rb b/spec/lib/gitlab/ci/components/input/args/options_spec.rb index 1412611aec49f6..2c76fe708ffcca 100644 --- a/spec/lib/gitlab/ci/components/input/args/options_spec.rb +++ b/spec/lib/gitlab/ci/components/input/args/options_spec.rb @@ -28,4 +28,14 @@ expect(argument).not_to be_valid end + + describe '.matches?' do + it 'matches specs with options configuration' do + expect(described_class.matches?({ options: %w[a b] })).to be true + end + + it 'does not match specs different configuration keyword' do + expect(described_class.matches?({ default: 'abc' })).to be false + end + end end diff --git a/spec/lib/gitlab/ci/components/input/args/required_spec.rb b/spec/lib/gitlab/ci/components/input/args/required_spec.rb index 022648d15bb1f4..0c87940fbbfdca 100644 --- a/spec/lib/gitlab/ci/components/input/args/required_spec.rb +++ b/spec/lib/gitlab/ci/components/input/args/required_spec.rb @@ -22,4 +22,14 @@ expect(argument).not_to be_valid end + + describe '.matches?' do + it 'matches specs without configuration' do + expect(described_class.matches?(nil)).to be true + end + + it 'does not match specs with configuration' do + expect(described_class.matches?({ options: %w[a b] })).to be false + end + end end diff --git a/spec/lib/gitlab/ci/components/input/arguments_spec.rb b/spec/lib/gitlab/ci/components/input/arguments_spec.rb index 0f07e1e6bbc347..f04cfd52e803e6 100644 --- a/spec/lib/gitlab/ci/components/input/arguments_spec.rb +++ b/spec/lib/gitlab/ci/components/input/arguments_spec.rb @@ -6,10 +6,31 @@ let(:specification_class) { ::Gitlab::Ci::Components::Input::Specification } it 'describes user-provided inputs' do - spec = specification_class.new({ webiste: nil }) + spec = specification_class.new({ website: nil }) args = described_class.new(spec, { website: 'http://example.gitlab.com' }) expect(args).to be_valid end + + context 'when proper specification has been provided' do + let(:spec) do + specification_class.new( + { + website: nil, + env: { default: 'development' }, + run: { options: %w[tests spec e2e] } + } + ) + end + + let(:args) { { website: 'https://gitlab.com', run: 'tests' } } + + it 'fabricates desired input arguments' do + arguments = described_class.new(spec, args) + + expect(arguments).to be_valid + expect(arguments.count).to eq 3 + end + end end -- GitLab From 71e570b4960c61aab87f15eac211afaa210197d2 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 22 Feb 2023 13:12:24 +0100 Subject: [PATCH 05/15] Simplify CI config inputs modules and classes --- lib/gitlab/ci/components/header.rb | 29 ++++----- lib/gitlab/ci/components/input/args/base.rb | 64 ------------------ .../ci/components/input/args/default.rb | 32 --------- .../ci/components/input/args/options.rb | 35 ---------- .../ci/components/input/args/required.rb | 31 --------- lib/gitlab/ci/components/input/arguments.rb | 64 ------------------ .../ci/components/input/specification.rb | 49 -------------- lib/gitlab/ci/inputs/arguments/base.rb | 62 ++++++++++++++++++ lib/gitlab/ci/inputs/arguments/default.rb | 30 +++++++++ lib/gitlab/ci/inputs/arguments/options.rb | 33 ++++++++++ lib/gitlab/ci/inputs/arguments/required.rb | 29 +++++++++ lib/gitlab/ci/inputs/input.rb | 65 +++++++++++++++++++ .../ci/components/input/arguments_spec.rb | 36 ---------- .../ci/components/input/specification_spec.rb | 11 ---- .../args => inputs/arguments}/base_spec.rb | 2 +- .../args => inputs/arguments}/default_spec.rb | 2 +- .../args => inputs/arguments}/options_spec.rb | 2 +- .../arguments}/required_spec.rb | 2 +- spec/lib/gitlab/ci/inputs/input_spec.rb | 34 ++++++++++ 19 files changed, 268 insertions(+), 344 deletions(-) delete mode 100644 lib/gitlab/ci/components/input/args/base.rb delete mode 100644 lib/gitlab/ci/components/input/args/default.rb delete mode 100644 lib/gitlab/ci/components/input/args/options.rb delete mode 100644 lib/gitlab/ci/components/input/args/required.rb delete mode 100644 lib/gitlab/ci/components/input/arguments.rb delete mode 100644 lib/gitlab/ci/components/input/specification.rb create mode 100644 lib/gitlab/ci/inputs/arguments/base.rb create mode 100644 lib/gitlab/ci/inputs/arguments/default.rb create mode 100644 lib/gitlab/ci/inputs/arguments/options.rb create mode 100644 lib/gitlab/ci/inputs/arguments/required.rb create mode 100644 lib/gitlab/ci/inputs/input.rb delete mode 100644 spec/lib/gitlab/ci/components/input/arguments_spec.rb delete mode 100644 spec/lib/gitlab/ci/components/input/specification_spec.rb rename spec/lib/gitlab/ci/{components/input/args => inputs/arguments}/base_spec.rb (81%) rename spec/lib/gitlab/ci/{components/input/args => inputs/arguments}/default_spec.rb (94%) rename spec/lib/gitlab/ci/{components/input/args => inputs/arguments}/options_spec.rb (92%) rename spec/lib/gitlab/ci/{components/input/args => inputs/arguments}/required_spec.rb (90%) create mode 100644 spec/lib/gitlab/ci/inputs/input_spec.rb diff --git a/lib/gitlab/ci/components/header.rb b/lib/gitlab/ci/components/header.rb index 57d06fabfba30a..0b39a5a5c99f2f 100644 --- a/lib/gitlab/ci/components/header.rb +++ b/lib/gitlab/ci/components/header.rb @@ -8,36 +8,29 @@ module Components # in the CI Component file. # class Header + attr_reader :errors + def initialize(header) @header = header + @errors = [] + + @errors.push('component header is empty') if @header.to_h.empty? end - def errors - spec.errors + def version + raise NotImplementedError end def valid? - errors.none? # TODO + errors.none? end def spec - @spec ||= Components::Input::Specification - .new(spec_inputs_hash) - end - - def to_args(inputs) - @args ||= Components::Input::Arguments - .new(spec, inputs) - end - - def version - raise NotImplementedError + @header.dig(:spec, :inputs) end - private - - def spec_inputs_hash - @header.dig(:spec, :inputs) + def to_input(args) + @input ||= Components::Inputs::Input.new(spec, args) end end end diff --git a/lib/gitlab/ci/components/input/args/base.rb b/lib/gitlab/ci/components/input/args/base.rb deleted file mode 100644 index 70ba4750f93550..00000000000000 --- a/lib/gitlab/ci/components/input/args/base.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Components - module Input - module Args - ## - # Components::Input::Args::Base is a common abstraction for input arguments: - # - required - # - optional - # - with a default value - # - class Base - attr_reader :key, :value, :spec, :errors - - ArgumentNotValidError = Class.new(StandardError) - - def initialize(key, spec, value) - @key = key # hash key / argument name - @value = value # user-provided value - @spec = spec # configured specification - @errors = [] - - unless value.is_a?(String) || value.nil? # rubocop:disable Style/IfUnlessModifier - @errors.push("unsupported value in input argument `#{key}`") - end - - validate! - end - - def valid? - @errors.none? - end - - def validate! - raise NotImplementedError - end - - def to_value - raise NotImplementedError - end - - def to_hash - raise ArgumentNotValidError unless valid? - - @output ||= { key => to_value } - end - - def self.matches?(spec) - raise NotImplementedError - end - - private - - def error(message) - @errors.push("`#{@key}` input: #{message}") - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/components/input/args/default.rb b/lib/gitlab/ci/components/input/args/default.rb deleted file mode 100644 index be8d8567afa406..00000000000000 --- a/lib/gitlab/ci/components/input/args/default.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Components - module Input - module Args - ## - # Components::Input::Args::Default class represents user-provided input argument that has a default value. - # - class Default < Input::Args::Base - def validate! - error('invalid specification') unless default.present? - end - - def to_value - value.to_s.empty? ? default : value - end - - def default - spec[:default] - end - - def self.matches?(spec) - spec.count == 1 && spec.each_key.first == :default - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/components/input/args/options.rb b/lib/gitlab/ci/components/input/args/options.rb deleted file mode 100644 index ba57729fa3ae6e..00000000000000 --- a/lib/gitlab/ci/components/input/args/options.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Components - module Input - module Args - ## - # Components::Input::Args::Options class represents user-provided input argument that is an enum, and is only - # valid when the value provided is listed as an acceptable one. - # - class Options < Input::Args::Base - def validate! - return error('argument specification invalid') if options.to_a.empty? - - error('argument value not allowed') unless options.include?(value) - end - - def to_value - value - end - - def options - spec[:options] - end - - def self.matches?(spec) - spec.count == 1 && spec.each_key.first == :options - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/components/input/args/required.rb b/lib/gitlab/ci/components/input/args/required.rb deleted file mode 100644 index f6a315cdf9b4fe..00000000000000 --- a/lib/gitlab/ci/components/input/args/required.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Components - module Input - module Args - ## - # Components::Input::Args::Required class represents user-provided required input argument. - # - class Required < Input::Args::Base - def validate! - error('required value has not been provided') if value.to_s.empty? - end - - def to_value - value - end - - ## - # TODO how to handle empty string here? - # - def self.matches?(spec) - spec.nil? - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/components/input/arguments.rb b/lib/gitlab/ci/components/input/arguments.rb deleted file mode 100644 index bd4a381671825d..00000000000000 --- a/lib/gitlab/ci/components/input/arguments.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Components - module Input - ## - # Components::Input::Arguments class represents user-provided inputs, configured using `with:` keyword. - # - # Input arguments are only valid with an associated component's inputs specification from component's header. - # - class Arguments - include Enumerable - - UnknownSpecArgumentError = Class.new(StandardError) - - ARGUMENTS = [ - Input::Args::Required, # Input argument is required - Input::Args::Default, # Input argument has a default value - Input::Args::Options # Input argument that needs to be allowlisted - ].freeze - - def initialize(spec, args) - @spec = spec - @args = args - @inputs = [] - @errors = [] - - validate! - fabricate! - end - - def each(&block) - @inputs.each(&block) - end - - def valid? - @errors.none? && @spec.valid? # TODO @inputs - end - - def unknown - @args.keys - @spec.keys - end - - private - - def validate! - @errors.push("unspecified input arguments: #{unknown.inspect}") if unknown.any? - end - - def fabricate! - @spec.each do |key, spec| - argument = ARGUMENTS.find { |klass| klass.matches?(spec) } - - raise UnknownSpecArgumentError if argument.nil? - - @inputs.push(argument.new(key, spec, @args[key])) - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/components/input/specification.rb b/lib/gitlab/ci/components/input/specification.rb deleted file mode 100644 index b56202ddbd40b9..00000000000000 --- a/lib/gitlab/ci/components/input/specification.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Components - module Input - ## - # Components::Input class represents user-provided inputs, configured using `with:` keyword. - # - # Inputs are only valid with an associated component's header. - # - class Specification - include Gitlab::Utils::StrongMemoize - - attr_reader :errors - - def initialize(spec) - @spec = spec - @errors = [] - - validate! - end - - def valid? - @errors.none? - end - - def empty? - @spec.to_h.empty? - end - - def each(&block) - @spec.each_pair(&block) - end - - def keys - @spec.keys - end - - private - - def validate! - @errors.push('component specification is empty') if empty? - end - end - end - end - end -end diff --git a/lib/gitlab/ci/inputs/arguments/base.rb b/lib/gitlab/ci/inputs/arguments/base.rb new file mode 100644 index 00000000000000..0ce7eef405d781 --- /dev/null +++ b/lib/gitlab/ci/inputs/arguments/base.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Inputs + module Arguments + ## + # Inputs::Arguments::Base is a common abstraction for input arguments: + # - required + # - optional + # - with a default value + # + class Base + attr_reader :key, :value, :spec, :errors + + ArgumentNotValidError = Class.new(StandardError) + + def initialize(key, spec, value) + @key = key # hash key / argument name + @value = value # user-provided value + @spec = spec # configured specification + @errors = [] + + unless value.is_a?(String) || value.nil? # rubocop:disable Style/IfUnlessModifier + @errors.push("unsupported value in input argument `#{key}`") + end + + validate! + end + + def valid? + @errors.none? + end + + def validate! + raise NotImplementedError + end + + def to_value + raise NotImplementedError + end + + def to_hash + raise ArgumentNotValidError unless valid? + + @output ||= { key => to_value } + end + + def self.matches?(spec) + raise NotImplementedError + end + + private + + def error(message) + @errors.push("`#{@key}` input: #{message}") + end + end + end + end + end +end diff --git a/lib/gitlab/ci/inputs/arguments/default.rb b/lib/gitlab/ci/inputs/arguments/default.rb new file mode 100644 index 00000000000000..d5192f13f6f808 --- /dev/null +++ b/lib/gitlab/ci/inputs/arguments/default.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Inputs + module Arguments + ## + # Inputs::Arguments::Default class represents user-provided input argument that has a default value. + # + class Default < Inputs::Arguments::Base + def validate! + error('invalid specification') unless default.present? + end + + def to_value + value.to_s.empty? ? default : value + end + + def default + spec[:default] + end + + def self.matches?(spec) + spec.count == 1 && spec.each_key.first == :default + end + end + end + end + end +end diff --git a/lib/gitlab/ci/inputs/arguments/options.rb b/lib/gitlab/ci/inputs/arguments/options.rb new file mode 100644 index 00000000000000..50a609d5982bfc --- /dev/null +++ b/lib/gitlab/ci/inputs/arguments/options.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Inputs + module Arguments + ## + # Inputs::Arguments::Options class represents user-provided input argument that is an enum, and is only valid + # when the value provided is listed as an acceptable one. + # + class Options < Inputs::Arguments::Base + def validate! + return error('argument specification invalid') if options.to_a.empty? + + error('argument value not allowed') unless options.include?(value) + end + + def to_value + value + end + + def options + spec[:options] + end + + def self.matches?(spec) + spec.count == 1 && spec.each_key.first == :options + end + end + end + end + end +end diff --git a/lib/gitlab/ci/inputs/arguments/required.rb b/lib/gitlab/ci/inputs/arguments/required.rb new file mode 100644 index 00000000000000..2374a2de71a5fd --- /dev/null +++ b/lib/gitlab/ci/inputs/arguments/required.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Inputs + module Arguments + ## + # Inputs::Arguments::Required class represents user-provided required input argument. + # + class Required < Inputs::Arguments::Base + def validate! + error('required value has not been provided') if value.to_s.empty? + end + + def to_value + value + end + + ## + # TODO how to handle empty string here? + # + def self.matches?(spec) + spec.nil? + end + end + end + end + end +end diff --git a/lib/gitlab/ci/inputs/input.rb b/lib/gitlab/ci/inputs/input.rb new file mode 100644 index 00000000000000..23b56b30a1734c --- /dev/null +++ b/lib/gitlab/ci/inputs/input.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Inputs + ## + # Inputs::Input class represents user-provided inputs, configured using `with:` keyword. + # + # Input arguments are only valid with an associated component's inputs specification from component's header. + # + class Input + UnknownSpecArgumentError = Class.new(StandardError) + + ARGUMENTS = [ + Inputs::Arguments::Required, # Input argument is required + Inputs::Arguments::Default, # Input argument has a default value + Inputs::Arguments::Options # Input argument that needs to be allowlisted + ].freeze + + def initialize(spec, args) + @spec = spec + @args = args + @inputs = [] + @errors = [] + + validate! + fabricate! + end + + # TODO more specs + def errors + @errors + @inputs.flat_map(&:errors) + end + + def valid? + @errors.none? + end + + def unknown + @args.keys - @spec.keys + end + + def count + @inputs.count + end + + private + + def validate! + @errors.push("unspecified input arguments: #{unknown.inspect}") if unknown.any? + end + + def fabricate! + @spec.each do |key, spec| + argument = ARGUMENTS.find { |klass| klass.matches?(spec) } + + raise UnknownSpecArgumentError if argument.nil? + + @inputs.push(argument.new(key, spec, @args[key])) + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/components/input/arguments_spec.rb b/spec/lib/gitlab/ci/components/input/arguments_spec.rb deleted file mode 100644 index f04cfd52e803e6..00000000000000 --- a/spec/lib/gitlab/ci/components/input/arguments_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' - -RSpec.describe Gitlab::Ci::Components::Input::Arguments, feature_category: :pipeline_authoring do - let(:specification_class) { ::Gitlab::Ci::Components::Input::Specification } - - it 'describes user-provided inputs' do - spec = specification_class.new({ website: nil }) - - args = described_class.new(spec, { website: 'http://example.gitlab.com' }) - - expect(args).to be_valid - end - - context 'when proper specification has been provided' do - let(:spec) do - specification_class.new( - { - website: nil, - env: { default: 'development' }, - run: { options: %w[tests spec e2e] } - } - ) - end - - let(:args) { { website: 'https://gitlab.com', run: 'tests' } } - - it 'fabricates desired input arguments' do - arguments = described_class.new(spec, args) - - expect(arguments).to be_valid - expect(arguments.count).to eq 3 - end - end -end diff --git a/spec/lib/gitlab/ci/components/input/specification_spec.rb b/spec/lib/gitlab/ci/components/input/specification_spec.rb deleted file mode 100644 index be661e2839a7df..00000000000000 --- a/spec/lib/gitlab/ci/components/input/specification_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' - -RSpec.describe Gitlab::Ci::Components::Input::Specification, feature_category: :pipeline_authoring do - it 'describes component inputs specification' do - spec = described_class.new({ website: nil }) - - expect(spec).to be_valid - end -end diff --git a/spec/lib/gitlab/ci/components/input/args/base_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb similarity index 81% rename from spec/lib/gitlab/ci/components/input/args/base_spec.rb rename to spec/lib/gitlab/ci/inputs/arguments/base_spec.rb index 237404f8619cfc..494d480dc72972 100644 --- a/spec/lib/gitlab/ci/components/input/args/base_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Components::Input::Args::Base, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Base, feature_category: :pipeline_authoring do subject do Class.new(described_class) do def validate!; end diff --git a/spec/lib/gitlab/ci/components/input/args/default_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb similarity index 94% rename from spec/lib/gitlab/ci/components/input/args/default_spec.rb rename to spec/lib/gitlab/ci/inputs/arguments/default_spec.rb index 72cb9639939c0b..ebb739a23a61b4 100644 --- a/spec/lib/gitlab/ci/components/input/args/default_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Components::Input::Args::Default, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Default, feature_category: :pipeline_authoring do it 'returns a user-provided value if it is present' do argument = described_class.new(:website, { default: 'https://gitlab.com' }, 'https://example.gitlab.com') diff --git a/spec/lib/gitlab/ci/components/input/args/options_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb similarity index 92% rename from spec/lib/gitlab/ci/components/input/args/options_spec.rb rename to spec/lib/gitlab/ci/inputs/arguments/options_spec.rb index 2c76fe708ffcca..d5a1a048333a3c 100644 --- a/spec/lib/gitlab/ci/components/input/args/options_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Components::Input::Args::Options, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Options, feature_category: :pipeline_authoring do it 'returns a user-provided value if it is an allowed one' do argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt1') diff --git a/spec/lib/gitlab/ci/components/input/args/required_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb similarity index 90% rename from spec/lib/gitlab/ci/components/input/args/required_spec.rb rename to spec/lib/gitlab/ci/inputs/arguments/required_spec.rb index 0c87940fbbfdca..b5a212a3cfb761 100644 --- a/spec/lib/gitlab/ci/components/input/args/required_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Components::Input::Args::Required, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Required, feature_category: :pipeline_authoring do it 'returns a user-provided value if it is present' do argument = described_class.new(:website, nil, 'https://example.gitlab.com') diff --git a/spec/lib/gitlab/ci/inputs/input_spec.rb b/spec/lib/gitlab/ci/inputs/input_spec.rb new file mode 100644 index 00000000000000..84654f396d58e6 --- /dev/null +++ b/spec/lib/gitlab/ci/inputs/input_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Inputs::Input, feature_category: :pipeline_authoring do + describe '#valid?' do + let(:spec) { { website: nil } } + + it 'describes user-provided inputs' do + input = described_class.new(spec, { website: 'http://example.gitlab.com' }) + + expect(input).to be_valid + end + end + + context 'when proper specification has been provided' do + let(:spec) do + { + website: nil, + env: { default: 'development' }, + run: { options: %w[tests spec e2e] } + } + end + + let(:args) { { website: 'https://gitlab.com', run: 'tests' } } + + it 'fabricates desired input arguments' do + input = described_class.new(spec, args) + + expect(input).to be_valid + expect(input.count).to eq 3 + end + end +end -- GitLab From 33ebbf87eef27cc4d7f781f6cdffa5e8bef183d3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 22 Feb 2023 13:40:26 +0100 Subject: [PATCH 06/15] Improve CI inputs arguments reconciliation mechanism --- lib/gitlab/ci/components/header.rb | 16 +++++++++--- lib/gitlab/ci/inputs/arguments/options.rb | 6 ++++- lib/gitlab/ci/inputs/input.rb | 11 ++++++-- spec/lib/gitlab/ci/components/header_spec.rb | 26 +++++++++++++++++++ spec/lib/gitlab/ci/inputs/input_spec.rb | 27 ++++++++++++++++++++ 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/lib/gitlab/ci/components/header.rb b/lib/gitlab/ci/components/header.rb index 0b39a5a5c99f2f..c9499dbfeb49fe 100644 --- a/lib/gitlab/ci/components/header.rb +++ b/lib/gitlab/ci/components/header.rb @@ -14,7 +14,7 @@ def initialize(header) @header = header @errors = [] - @errors.push('component header is empty') if @header.to_h.empty? + @errors.push('component inputs spec is empty') if inputs_spec.to_h.empty? end def version @@ -25,12 +25,20 @@ def valid? errors.none? end - def spec + def inputs_spec @header.dig(:spec, :inputs) end - def to_input(args) - @input ||= Components::Inputs::Input.new(spec, args) + def input(args) + @input ||= Ci::Inputs::Input.new(inputs_spec, args) + end + + def context(args) + input(args).then do |inputs| + raise ArgumentError unless inputs.valid? + + Ci::Interpolation::Context.new({ inputs: inputs.to_hash }) + end end end end diff --git a/lib/gitlab/ci/inputs/arguments/options.rb b/lib/gitlab/ci/inputs/arguments/options.rb index 50a609d5982bfc..1a9a7b319e9132 100644 --- a/lib/gitlab/ci/inputs/arguments/options.rb +++ b/lib/gitlab/ci/inputs/arguments/options.rb @@ -12,7 +12,11 @@ class Options < Inputs::Arguments::Base def validate! return error('argument specification invalid') if options.to_a.empty? - error('argument value not allowed') unless options.include?(value) + if value.present? + error("argument value #{value} not allowed") unless options.include?(value) + else + error('argument not provided') + end end def to_value diff --git a/lib/gitlab/ci/inputs/input.rb b/lib/gitlab/ci/inputs/input.rb index 23b56b30a1734c..4ed93d7d91680d 100644 --- a/lib/gitlab/ci/inputs/input.rb +++ b/lib/gitlab/ci/inputs/input.rb @@ -27,13 +27,12 @@ def initialize(spec, args) fabricate! end - # TODO more specs def errors @errors + @inputs.flat_map(&:errors) end def valid? - @errors.none? + errors.none? end def unknown @@ -44,6 +43,14 @@ def count @inputs.count end + def to_hash + @inputs.inject({}) do |hash, argument| + raise ArgumentError unless argument.valid? + + hash.merge(argument.to_hash) + end + end + private def validate! diff --git a/spec/lib/gitlab/ci/components/header_spec.rb b/spec/lib/gitlab/ci/components/header_spec.rb index 3164d25e9fa83c..af3def1de19ee9 100644 --- a/spec/lib/gitlab/ci/components/header_spec.rb +++ b/spec/lib/gitlab/ci/components/header_spec.rb @@ -22,5 +22,31 @@ it 'fabricates a spec from valid data' do expect(subject).to be_valid end + + describe '.input' do + it 'fabricates input data' do + input = subject.input({ website: 'https//gitlab.com', run: 'opt1' }) + + expect(input).to be_valid + expect(input.count).to eq 2 + end + end + + describe '.context' do + it 'fabricates interpolation context' do + ctx = subject.context({ website: 'https//gitlab.com', run: 'opt1' }) + + expect(ctx).to be_valid + end + end + end + + context 'when spec is empty' do + let(:spec) { { spec: {} } } + + it 'returns an error' do + expect(subject).not_to be_valid + expect(subject.errors.first).to eq 'component inputs spec is empty' + end end end diff --git a/spec/lib/gitlab/ci/inputs/input_spec.rb b/spec/lib/gitlab/ci/inputs/input_spec.rb index 84654f396d58e6..fe2da5c62dd270 100644 --- a/spec/lib/gitlab/ci/inputs/input_spec.rb +++ b/spec/lib/gitlab/ci/inputs/input_spec.rb @@ -29,6 +29,33 @@ expect(input).to be_valid expect(input.count).to eq 3 + expect(input.to_hash).to eq(args.merge(env: 'development')) + end + end + + context 'when there are arguments recoincilation errors present' do + context 'when required argument is missing' do + let(:spec) { { website: nil } } + + it 'returns an error' do + input = described_class.new(spec, {}) + + expect(input).not_to be_valid + expect(input.errors.first).to eq '`website` input: required value has not been provided' + end + end + + context 'when argument is not present but configured as allowlist' do + let(:spec) do + { run: { options: %w[opt1 opt2] } } + end + + it 'returns an error' do + input = described_class.new(spec, {}) + + expect(input).not_to be_valid + expect(input.errors.first).to eq '`run` input: argument not provided' + end end end end -- GitLab From e755d4c87175970a914dabb5ab6e563b05e17d36 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 22 Feb 2023 14:00:48 +0100 Subject: [PATCH 07/15] Better define a required CI input argument specification --- lib/gitlab/ci/inputs/arguments/required.rb | 16 ++++++++++++++-- .../gitlab/ci/inputs/arguments/required_spec.rb | 4 ++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/ci/inputs/arguments/required.rb b/lib/gitlab/ci/inputs/arguments/required.rb index 2374a2de71a5fd..494138abe93462 100644 --- a/lib/gitlab/ci/inputs/arguments/required.rb +++ b/lib/gitlab/ci/inputs/arguments/required.rb @@ -17,10 +17,22 @@ def to_value end ## - # TODO how to handle empty string here? + # Required arguments does not have nested configuration. Following definitions both describe a required + # argument: # + # An undefined value (similarly to how this is done in Ruby keyword argument: `def initialize(website:)`. + # + # ```yaml + # website: + # ``` + # + # An empty value, that has no specification. It also describes a required argument. + # + # ```yaml + # website: "" + # ``` def self.matches?(spec) - spec.nil? + spec.to_s.empty? end end end diff --git a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb index b5a212a3cfb761..7149152f7e128b 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb @@ -28,6 +28,10 @@ expect(described_class.matches?(nil)).to be true end + it 'matches specs with empty configuration' do + expect(described_class.matches?('')).to be true + end + it 'does not match specs with configuration' do expect(described_class.matches?({ options: %w[a b] })).to be false end -- GitLab From a41ff684cbaafde05e429e1e3bf8c874003d9215 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 23 Feb 2023 15:13:31 +0100 Subject: [PATCH 08/15] Add more clarity around how CI input arguments behave --- lib/gitlab/ci/inputs/arguments/default.rb | 16 +++++++++++++++- lib/gitlab/ci/inputs/arguments/options.rb | 19 +++++++++++++++++-- lib/gitlab/ci/inputs/arguments/required.rb | 5 ++++- .../ci/inputs/arguments/default_spec.rb | 9 +++------ .../ci/inputs/arguments/options_spec.rb | 11 +++++++++++ .../ci/inputs/arguments/required_spec.rb | 8 +++++--- 6 files changed, 55 insertions(+), 13 deletions(-) diff --git a/lib/gitlab/ci/inputs/arguments/default.rb b/lib/gitlab/ci/inputs/arguments/default.rb index d5192f13f6f808..9fa5420c7a5ac8 100644 --- a/lib/gitlab/ci/inputs/arguments/default.rb +++ b/lib/gitlab/ci/inputs/arguments/default.rb @@ -12,8 +12,22 @@ def validate! error('invalid specification') unless default.present? end + ## + # User-provided value needs to be specified, but it may be an empty string: + # + # ```yaml + # inputs: + # env: + # default: development + # + # with: + # env: "" + # ``` + # + # The configuration above will result in `env` being an empty string. + # def to_value - value.to_s.empty? ? default : value + value.nil? ? default : value end def default diff --git a/lib/gitlab/ci/inputs/arguments/options.rb b/lib/gitlab/ci/inputs/arguments/options.rb index 1a9a7b319e9132..ad416212421725 100644 --- a/lib/gitlab/ci/inputs/arguments/options.rb +++ b/lib/gitlab/ci/inputs/arguments/options.rb @@ -9,11 +9,26 @@ module Arguments # when the value provided is listed as an acceptable one. # class Options < Inputs::Arguments::Base + ## + # An empty value is valid if it is allowlisted: + # + # ```yaml + # inputs: + # run: + # - "" + # - tests + # + # with: + # run: "" + # ``` + # + # The configuration above will return an empty value. + # def validate! return error('argument specification invalid') if options.to_a.empty? - if value.present? - error("argument value #{value} not allowed") unless options.include?(value) + if !value.nil? + error("argument value #{value} not allowlisted") unless options.include?(value) else error('argument not provided') end diff --git a/lib/gitlab/ci/inputs/arguments/required.rb b/lib/gitlab/ci/inputs/arguments/required.rb index 494138abe93462..444f9ae7fa821c 100644 --- a/lib/gitlab/ci/inputs/arguments/required.rb +++ b/lib/gitlab/ci/inputs/arguments/required.rb @@ -8,8 +8,11 @@ module Arguments # Inputs::Arguments::Required class represents user-provided required input argument. # class Required < Inputs::Arguments::Base + ## + # The value has to be define, but it may be empty. + # def validate! - error('required value has not been provided') if value.to_s.empty? + error('required value has not been provided') if value.nil? end def to_value diff --git a/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb index ebb739a23a61b4..542989f5673023 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb @@ -11,15 +11,12 @@ expect(argument.to_hash).to eq({ website: 'https://example.gitlab.com' }) end - ## - # TODO: how it should behaving in this case? Should we use a default value or an empty one? - # - it 'returns a default value if user-provider one is empty' do + it 'returns an empty value if user-provider input is empty' do argument = described_class.new(:website, { default: 'https://gitlab.com' }, '') expect(argument).to be_valid - expect(argument.to_value).to eq 'https://gitlab.com' - expect(argument.to_hash).to eq({ website: 'https://gitlab.com' }) + expect(argument.to_value).to eq '' + expect(argument.to_hash).to eq({ website: '' }) end it 'returns a default value if user-provider one is unknown' do diff --git a/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb index d5a1a048333a3c..c7981f8e71c0a3 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb @@ -15,18 +15,29 @@ argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt3') expect(argument).not_to be_valid + expect(argument.errors.first).to eq '`run` input: argument value opt3 not allowlisted' end it 'returns an error if specification is not correct' do argument = described_class.new(:website, { options: nil }, 'opt1') expect(argument).not_to be_valid + expect(argument.errors.first).to eq '`website` input: argument specification invalid' end it 'returns an error if specification is using a hash' do argument = described_class.new(:website, { options: { a: 1 } }, 'opt1') expect(argument).not_to be_valid + expect(argument.errors.first).to eq '`website` input: argument value opt1 not allowlisted' + end + + it 'returns an empty value if it is allowlisted' do + argument = described_class.new(:run, { options: ['opt1', ''] }, '') + + expect(argument).to be_valid + expect(argument.to_value).to be_empty + expect(argument.to_hash).to eq({ run: '' }) end describe '.matches?' do diff --git a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb index 7149152f7e128b..a8716138b7b47a 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb @@ -11,16 +11,18 @@ expect(argument.to_hash).to eq({ website: 'https://example.gitlab.com' }) end - it 'returns an error if user-provided value is empty' do + it 'returns an empty value if user-provider value is empty' do argument = described_class.new(:website, nil, '') - expect(argument).not_to be_valid + expect(argument).to be_valid + expect(argument.to_hash).to eq(website: '') end - it 'returns an error if user-provided value is not specified' do + it 'returns an error if user-provided value is unspecified' do argument = described_class.new(:website, nil, nil) expect(argument).not_to be_valid + expect(argument.errors.first).to eq '`website` input: required value has not been provided' end describe '.matches?' do -- GitLab From 21f134d6546a9eb14e20f704183ccd770e9ccb75 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 23 Feb 2023 15:17:47 +0100 Subject: [PATCH 09/15] Rename product category for CI catalog specs --- spec/lib/gitlab/ci/components/header_spec.rb | 2 +- spec/lib/gitlab/ci/inputs/arguments/base_spec.rb | 2 +- spec/lib/gitlab/ci/inputs/arguments/default_spec.rb | 2 +- spec/lib/gitlab/ci/inputs/arguments/options_spec.rb | 2 +- spec/lib/gitlab/ci/inputs/arguments/required_spec.rb | 2 +- spec/lib/gitlab/ci/inputs/input_spec.rb | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/lib/gitlab/ci/components/header_spec.rb b/spec/lib/gitlab/ci/components/header_spec.rb index af3def1de19ee9..5c4269c7489584 100644 --- a/spec/lib/gitlab/ci/components/header_spec.rb +++ b/spec/lib/gitlab/ci/components/header_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Components::Header, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Components::Header, feature_category: :pipeline_composition do subject { described_class.new(spec) } context 'when spec is valid' do diff --git a/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb index 494d480dc72972..8e4b131f6766ba 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Base, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Base, feature_category: :pipeline_composition do subject do Class.new(described_class) do def validate!; end diff --git a/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb index 542989f5673023..b2b92620133028 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Default, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Default, feature_category: :pipeline_composition do it 'returns a user-provided value if it is present' do argument = described_class.new(:website, { default: 'https://gitlab.com' }, 'https://example.gitlab.com') diff --git a/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb index c7981f8e71c0a3..0135ef1c23da39 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Options, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Options, feature_category: :pipeline_composition do it 'returns a user-provided value if it is an allowed one' do argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt1') diff --git a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb index a8716138b7b47a..2682bbf2d9eea4 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb +++ b/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Required, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Arguments::Required, feature_category: :pipeline_composition do it 'returns a user-provided value if it is present' do argument = described_class.new(:website, nil, 'https://example.gitlab.com') diff --git a/spec/lib/gitlab/ci/inputs/input_spec.rb b/spec/lib/gitlab/ci/inputs/input_spec.rb index fe2da5c62dd270..54dc2242ca0cc9 100644 --- a/spec/lib/gitlab/ci/inputs/input_spec.rb +++ b/spec/lib/gitlab/ci/inputs/input_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Input, feature_category: :pipeline_authoring do +RSpec.describe Gitlab::Ci::Inputs::Input, feature_category: :pipeline_composition do describe '#valid?' do let(:spec) { { website: nil } } -- GitLab From a27619da2a23a78b1ee5845f9eda591e268dd137 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 23 Feb 2023 15:26:22 +0100 Subject: [PATCH 10/15] Add support for unrecognized CI input argument being used --- lib/gitlab/ci/inputs/arguments/unknown.rb | 27 +++++++++++++++++++++++ lib/gitlab/ci/inputs/input.rb | 3 ++- spec/lib/gitlab/ci/inputs/input_spec.rb | 21 ++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 lib/gitlab/ci/inputs/arguments/unknown.rb diff --git a/lib/gitlab/ci/inputs/arguments/unknown.rb b/lib/gitlab/ci/inputs/arguments/unknown.rb new file mode 100644 index 00000000000000..f3a166eab1c2bb --- /dev/null +++ b/lib/gitlab/ci/inputs/arguments/unknown.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Inputs + module Arguments + ## + # Inputs::Arguments::Unknown object gets fabricated when we can't match an input argument entry with any known + # specification. It is matched as the last one, and always returns an error. + # + class Unknown < Inputs::Arguments::Base + def validate! + error("unrecognized input argument specification: `#{spec.each_key.first}`") + end + + def to_value + raise ArgumentError, 'unknown argument value' + end + + def self.matches?(*) + true + end + end + end + end + end +end diff --git a/lib/gitlab/ci/inputs/input.rb b/lib/gitlab/ci/inputs/input.rb index 4ed93d7d91680d..27cb72d95469f3 100644 --- a/lib/gitlab/ci/inputs/input.rb +++ b/lib/gitlab/ci/inputs/input.rb @@ -14,7 +14,8 @@ class Input ARGUMENTS = [ Inputs::Arguments::Required, # Input argument is required Inputs::Arguments::Default, # Input argument has a default value - Inputs::Arguments::Options # Input argument that needs to be allowlisted + Inputs::Arguments::Options, # Input argument that needs to be allowlisted + Inputs::Arguments::Unknown # Input argument has not been recognized ].freeze def initialize(spec, args) diff --git a/spec/lib/gitlab/ci/inputs/input_spec.rb b/spec/lib/gitlab/ci/inputs/input_spec.rb index 54dc2242ca0cc9..ba53f4a2c5af1e 100644 --- a/spec/lib/gitlab/ci/inputs/input_spec.rb +++ b/spec/lib/gitlab/ci/inputs/input_spec.rb @@ -58,4 +58,25 @@ end end end + + context 'when unknown specification argument has been used' do + let(:spec) do + { + website: nil, + env: { default: 'development' }, + run: { options: %w[tests spec e2e] }, + test: { unknown: 'something' } + } + end + + let(:args) { { website: 'https://gitlab.com', run: 'tests' } } + + it 'fabricates an unknown argument entry and returns an error' do + input = described_class.new(spec, args) + + expect(input).not_to be_valid + expect(input.count).to eq 4 + expect(input.errors.first).to eq '`test` input: unrecognized input argument specification: `unknown`' + end + end end -- GitLab From 26115641a8c47e29a2afc2fdca21d036e67357f3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 28 Feb 2023 13:13:37 +0100 Subject: [PATCH 11/15] Add support for empty CI component spec and arguments --- lib/gitlab/ci/components/header.rb | 28 +++++++++----------- spec/lib/gitlab/ci/components/header_spec.rb | 14 +++++----- spec/lib/gitlab/ci/inputs/input_spec.rb | 9 +++++++ 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/lib/gitlab/ci/components/header.rb b/lib/gitlab/ci/components/header.rb index c9499dbfeb49fe..6aa37be7277860 100644 --- a/lib/gitlab/ci/components/header.rb +++ b/lib/gitlab/ci/components/header.rb @@ -13,33 +13,29 @@ class Header def initialize(header) @header = header @errors = [] - - @errors.push('component inputs spec is empty') if inputs_spec.to_h.empty? - end - - def version - raise NotImplementedError end - def valid? - errors.none? - end - - def inputs_spec - @header.dig(:spec, :inputs) + def empty? + inputs_spec.to_h.empty? end - def input(args) + def inputs(args) @input ||= Ci::Inputs::Input.new(inputs_spec, args) end def context(args) - input(args).then do |inputs| - raise ArgumentError unless inputs.valid? + inputs(args).then do |input| + raise ArgumentError unless input.valid? - Ci::Interpolation::Context.new({ inputs: inputs.to_hash }) + Ci::Interpolation::Context.new({ inputs: input.to_hash }) end end + + private + + def inputs_spec + @header.dig(:spec, :inputs) + end end end end diff --git a/spec/lib/gitlab/ci/components/header_spec.rb b/spec/lib/gitlab/ci/components/header_spec.rb index 5c4269c7489584..b1af4ca92388bb 100644 --- a/spec/lib/gitlab/ci/components/header_spec.rb +++ b/spec/lib/gitlab/ci/components/header_spec.rb @@ -20,19 +20,18 @@ end it 'fabricates a spec from valid data' do - expect(subject).to be_valid + expect(subject).not_to be_empty end - describe '.input' do + describe '#inputs' do it 'fabricates input data' do - input = subject.input({ website: 'https//gitlab.com', run: 'opt1' }) + input = subject.inputs({ website: 'https//gitlab.com', run: 'opt1' }) - expect(input).to be_valid expect(input.count).to eq 2 end end - describe '.context' do + describe '#context' do it 'fabricates interpolation context' do ctx = subject.context({ website: 'https//gitlab.com', run: 'opt1' }) @@ -44,9 +43,8 @@ context 'when spec is empty' do let(:spec) { { spec: {} } } - it 'returns an error' do - expect(subject).not_to be_valid - expect(subject.errors.first).to eq 'component inputs spec is empty' + it 'returns an empty header' do + expect(subject).to be_empty end end end diff --git a/spec/lib/gitlab/ci/inputs/input_spec.rb b/spec/lib/gitlab/ci/inputs/input_spec.rb index ba53f4a2c5af1e..b8568fe9b5daa8 100644 --- a/spec/lib/gitlab/ci/inputs/input_spec.rb +++ b/spec/lib/gitlab/ci/inputs/input_spec.rb @@ -33,6 +33,15 @@ end end + context 'when inputs and args are empty' do + it 'is a valid use-case' do + input = described_class.new({}, {}) + + expect(input).to be_valid + expect(input.to_hash).to be_empty + end + end + context 'when there are arguments recoincilation errors present' do context 'when required argument is missing' do let(:spec) { { website: nil } } -- GitLab From c868c6280cce77d91bff894e09ebd04a2b195a06 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 28 Feb 2023 13:18:38 +0100 Subject: [PATCH 12/15] Rename Ci::Inputs module to Ci::Input --- lib/gitlab/ci/components/header.rb | 2 +- .../ci/{inputs => input}/arguments/base.rb | 4 +- .../ci/{inputs => input}/arguments/default.rb | 6 +-- .../ci/{inputs => input}/arguments/options.rb | 6 +-- .../{inputs => input}/arguments/required.rb | 6 +-- .../ci/{inputs => input}/arguments/unknown.rb | 6 +-- .../ci/{inputs/input.rb => input/inputs.rb} | 12 +++--- .../{inputs => input}/arguments/base_spec.rb | 2 +- .../arguments/default_spec.rb | 2 +- .../arguments/options_spec.rb | 2 +- .../arguments/required_spec.rb | 2 +- .../input_spec.rb => input/inputs_spec.rb} | 40 +++++++++---------- 12 files changed, 45 insertions(+), 45 deletions(-) rename lib/gitlab/ci/{inputs => input}/arguments/base.rb (93%) rename lib/gitlab/ci/{inputs => input}/arguments/default.rb (83%) rename lib/gitlab/ci/{inputs => input}/arguments/options.rb (85%) rename lib/gitlab/ci/{inputs => input}/arguments/required.rb (86%) rename lib/gitlab/ci/{inputs => input}/arguments/unknown.rb (73%) rename lib/gitlab/ci/{inputs/input.rb => input/inputs.rb} (81%) rename spec/lib/gitlab/ci/{inputs => input}/arguments/base_spec.rb (82%) rename spec/lib/gitlab/ci/{inputs => input}/arguments/default_spec.rb (94%) rename spec/lib/gitlab/ci/{inputs => input}/arguments/options_spec.rb (94%) rename spec/lib/gitlab/ci/{inputs => input}/arguments/required_spec.rb (92%) rename spec/lib/gitlab/ci/{inputs/input_spec.rb => input/inputs_spec.rb} (58%) diff --git a/lib/gitlab/ci/components/header.rb b/lib/gitlab/ci/components/header.rb index 6aa37be7277860..732874d7a8832c 100644 --- a/lib/gitlab/ci/components/header.rb +++ b/lib/gitlab/ci/components/header.rb @@ -20,7 +20,7 @@ def empty? end def inputs(args) - @input ||= Ci::Inputs::Input.new(inputs_spec, args) + @input ||= Ci::Input::Inputs.new(inputs_spec, args) end def context(args) diff --git a/lib/gitlab/ci/inputs/arguments/base.rb b/lib/gitlab/ci/input/arguments/base.rb similarity index 93% rename from lib/gitlab/ci/inputs/arguments/base.rb rename to lib/gitlab/ci/input/arguments/base.rb index 0ce7eef405d781..a46037c40cea2e 100644 --- a/lib/gitlab/ci/inputs/arguments/base.rb +++ b/lib/gitlab/ci/input/arguments/base.rb @@ -2,10 +2,10 @@ module Gitlab module Ci - module Inputs + module Input module Arguments ## - # Inputs::Arguments::Base is a common abstraction for input arguments: + # Input::Arguments::Base is a common abstraction for input arguments: # - required # - optional # - with a default value diff --git a/lib/gitlab/ci/inputs/arguments/default.rb b/lib/gitlab/ci/input/arguments/default.rb similarity index 83% rename from lib/gitlab/ci/inputs/arguments/default.rb rename to lib/gitlab/ci/input/arguments/default.rb index 9fa5420c7a5ac8..fd61c1ab78672e 100644 --- a/lib/gitlab/ci/inputs/arguments/default.rb +++ b/lib/gitlab/ci/input/arguments/default.rb @@ -2,12 +2,12 @@ module Gitlab module Ci - module Inputs + module Input module Arguments ## - # Inputs::Arguments::Default class represents user-provided input argument that has a default value. + # Input::Arguments::Default class represents user-provided input argument that has a default value. # - class Default < Inputs::Arguments::Base + class Default < Input::Arguments::Base def validate! error('invalid specification') unless default.present? end diff --git a/lib/gitlab/ci/inputs/arguments/options.rb b/lib/gitlab/ci/input/arguments/options.rb similarity index 85% rename from lib/gitlab/ci/inputs/arguments/options.rb rename to lib/gitlab/ci/input/arguments/options.rb index ad416212421725..debc89b10bd8f6 100644 --- a/lib/gitlab/ci/inputs/arguments/options.rb +++ b/lib/gitlab/ci/input/arguments/options.rb @@ -2,13 +2,13 @@ module Gitlab module Ci - module Inputs + module Input module Arguments ## - # Inputs::Arguments::Options class represents user-provided input argument that is an enum, and is only valid + # Input::Arguments::Options class represents user-provided input argument that is an enum, and is only valid # when the value provided is listed as an acceptable one. # - class Options < Inputs::Arguments::Base + class Options < Input::Arguments::Base ## # An empty value is valid if it is allowlisted: # diff --git a/lib/gitlab/ci/inputs/arguments/required.rb b/lib/gitlab/ci/input/arguments/required.rb similarity index 86% rename from lib/gitlab/ci/inputs/arguments/required.rb rename to lib/gitlab/ci/input/arguments/required.rb index 444f9ae7fa821c..87e8441b92adde 100644 --- a/lib/gitlab/ci/inputs/arguments/required.rb +++ b/lib/gitlab/ci/input/arguments/required.rb @@ -2,12 +2,12 @@ module Gitlab module Ci - module Inputs + module Input module Arguments ## - # Inputs::Arguments::Required class represents user-provided required input argument. + # Input::Arguments::Required class represents user-provided required input argument. # - class Required < Inputs::Arguments::Base + class Required < Input::Arguments::Base ## # The value has to be define, but it may be empty. # diff --git a/lib/gitlab/ci/inputs/arguments/unknown.rb b/lib/gitlab/ci/input/arguments/unknown.rb similarity index 73% rename from lib/gitlab/ci/inputs/arguments/unknown.rb rename to lib/gitlab/ci/input/arguments/unknown.rb index f3a166eab1c2bb..8f99f9bd5adb72 100644 --- a/lib/gitlab/ci/inputs/arguments/unknown.rb +++ b/lib/gitlab/ci/input/arguments/unknown.rb @@ -2,13 +2,13 @@ module Gitlab module Ci - module Inputs + module Input module Arguments ## - # Inputs::Arguments::Unknown object gets fabricated when we can't match an input argument entry with any known + # Input::Arguments::Unknown object gets fabricated when we can't match an input argument entry with any known # specification. It is matched as the last one, and always returns an error. # - class Unknown < Inputs::Arguments::Base + class Unknown < Input::Arguments::Base def validate! error("unrecognized input argument specification: `#{spec.each_key.first}`") end diff --git a/lib/gitlab/ci/inputs/input.rb b/lib/gitlab/ci/input/inputs.rb similarity index 81% rename from lib/gitlab/ci/inputs/input.rb rename to lib/gitlab/ci/input/inputs.rb index 27cb72d95469f3..df1eb49a89e14d 100644 --- a/lib/gitlab/ci/inputs/input.rb +++ b/lib/gitlab/ci/input/inputs.rb @@ -2,20 +2,20 @@ module Gitlab module Ci - module Inputs + module Input ## # Inputs::Input class represents user-provided inputs, configured using `with:` keyword. # # Input arguments are only valid with an associated component's inputs specification from component's header. # - class Input + class Inputs UnknownSpecArgumentError = Class.new(StandardError) ARGUMENTS = [ - Inputs::Arguments::Required, # Input argument is required - Inputs::Arguments::Default, # Input argument has a default value - Inputs::Arguments::Options, # Input argument that needs to be allowlisted - Inputs::Arguments::Unknown # Input argument has not been recognized + Input::Arguments::Required, # Input argument is required + Input::Arguments::Default, # Input argument has a default value + Input::Arguments::Options, # Input argument that needs to be allowlisted + Input::Arguments::Unknown # Input argument has not been recognized ].freeze def initialize(spec, args) diff --git a/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb b/spec/lib/gitlab/ci/input/arguments/base_spec.rb similarity index 82% rename from spec/lib/gitlab/ci/inputs/arguments/base_spec.rb rename to spec/lib/gitlab/ci/input/arguments/base_spec.rb index 8e4b131f6766ba..ed8e99b72571b0 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/base_spec.rb +++ b/spec/lib/gitlab/ci/input/arguments/base_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Base, feature_category: :pipeline_composition do +RSpec.describe Gitlab::Ci::Input::Arguments::Base, feature_category: :pipeline_composition do subject do Class.new(described_class) do def validate!; end diff --git a/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb b/spec/lib/gitlab/ci/input/arguments/default_spec.rb similarity index 94% rename from spec/lib/gitlab/ci/inputs/arguments/default_spec.rb rename to spec/lib/gitlab/ci/input/arguments/default_spec.rb index b2b92620133028..6b5dd441eb7004 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/default_spec.rb +++ b/spec/lib/gitlab/ci/input/arguments/default_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Default, feature_category: :pipeline_composition do +RSpec.describe Gitlab::Ci::Input::Arguments::Default, feature_category: :pipeline_composition do it 'returns a user-provided value if it is present' do argument = described_class.new(:website, { default: 'https://gitlab.com' }, 'https://example.gitlab.com') diff --git a/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb b/spec/lib/gitlab/ci/input/arguments/options_spec.rb similarity index 94% rename from spec/lib/gitlab/ci/inputs/arguments/options_spec.rb rename to spec/lib/gitlab/ci/input/arguments/options_spec.rb index 0135ef1c23da39..afa279ad48d279 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/options_spec.rb +++ b/spec/lib/gitlab/ci/input/arguments/options_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Options, feature_category: :pipeline_composition do +RSpec.describe Gitlab::Ci::Input::Arguments::Options, feature_category: :pipeline_composition do it 'returns a user-provided value if it is an allowed one' do argument = described_class.new(:run, { options: %w[opt1 opt2] }, 'opt1') diff --git a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb b/spec/lib/gitlab/ci/input/arguments/required_spec.rb similarity index 92% rename from spec/lib/gitlab/ci/inputs/arguments/required_spec.rb rename to spec/lib/gitlab/ci/input/arguments/required_spec.rb index 2682bbf2d9eea4..0c2ffc282eaba9 100644 --- a/spec/lib/gitlab/ci/inputs/arguments/required_spec.rb +++ b/spec/lib/gitlab/ci/input/arguments/required_spec.rb @@ -2,7 +2,7 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Arguments::Required, feature_category: :pipeline_composition do +RSpec.describe Gitlab::Ci::Input::Arguments::Required, feature_category: :pipeline_composition do it 'returns a user-provided value if it is present' do argument = described_class.new(:website, nil, 'https://example.gitlab.com') diff --git a/spec/lib/gitlab/ci/inputs/input_spec.rb b/spec/lib/gitlab/ci/input/inputs_spec.rb similarity index 58% rename from spec/lib/gitlab/ci/inputs/input_spec.rb rename to spec/lib/gitlab/ci/input/inputs_spec.rb index b8568fe9b5daa8..2abf0315aca872 100644 --- a/spec/lib/gitlab/ci/inputs/input_spec.rb +++ b/spec/lib/gitlab/ci/input/inputs_spec.rb @@ -2,14 +2,14 @@ require 'fast_spec_helper' -RSpec.describe Gitlab::Ci::Inputs::Input, feature_category: :pipeline_composition do +RSpec.describe Gitlab::Ci::Input::Inputs, feature_category: :pipeline_composition do describe '#valid?' do let(:spec) { { website: nil } } it 'describes user-provided inputs' do - input = described_class.new(spec, { website: 'http://example.gitlab.com' }) + inputs = described_class.new(spec, { website: 'http://example.gitlab.com' }) - expect(input).to be_valid + expect(inputs).to be_valid end end @@ -25,20 +25,20 @@ let(:args) { { website: 'https://gitlab.com', run: 'tests' } } it 'fabricates desired input arguments' do - input = described_class.new(spec, args) + inputs = described_class.new(spec, args) - expect(input).to be_valid - expect(input.count).to eq 3 - expect(input.to_hash).to eq(args.merge(env: 'development')) + expect(inputs).to be_valid + expect(inputs.count).to eq 3 + expect(inputs.to_hash).to eq(args.merge(env: 'development')) end end context 'when inputs and args are empty' do it 'is a valid use-case' do - input = described_class.new({}, {}) + inputs = described_class.new({}, {}) - expect(input).to be_valid - expect(input.to_hash).to be_empty + expect(inputs).to be_valid + expect(inputs.to_hash).to be_empty end end @@ -47,10 +47,10 @@ let(:spec) { { website: nil } } it 'returns an error' do - input = described_class.new(spec, {}) + inputs = described_class.new(spec, {}) - expect(input).not_to be_valid - expect(input.errors.first).to eq '`website` input: required value has not been provided' + expect(inputs).not_to be_valid + expect(inputs.errors.first).to eq '`website` input: required value has not been provided' end end @@ -60,10 +60,10 @@ end it 'returns an error' do - input = described_class.new(spec, {}) + inputs = described_class.new(spec, {}) - expect(input).not_to be_valid - expect(input.errors.first).to eq '`run` input: argument not provided' + expect(inputs).not_to be_valid + expect(inputs.errors.first).to eq '`run` input: argument not provided' end end end @@ -81,11 +81,11 @@ let(:args) { { website: 'https://gitlab.com', run: 'tests' } } it 'fabricates an unknown argument entry and returns an error' do - input = described_class.new(spec, args) + inputs = described_class.new(spec, args) - expect(input).not_to be_valid - expect(input.count).to eq 4 - expect(input.errors.first).to eq '`test` input: unrecognized input argument specification: `unknown`' + expect(inputs).not_to be_valid + expect(inputs.count).to eq 4 + expect(inputs.errors.first).to eq '`test` input: unrecognized input argument specification: `unknown`' end end end -- GitLab From 38c827bccc9d568d710fa64dcca58cab17a75b33 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 28 Feb 2023 13:24:01 +0100 Subject: [PATCH 13/15] Add specs for unknown CI input argument --- lib/gitlab/ci/input/arguments/unknown.rb | 6 +++++- .../gitlab/ci/input/arguments/unknown_spec.rb | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 spec/lib/gitlab/ci/input/arguments/unknown_spec.rb diff --git a/lib/gitlab/ci/input/arguments/unknown.rb b/lib/gitlab/ci/input/arguments/unknown.rb index 8f99f9bd5adb72..d3c85e30e7f88e 100644 --- a/lib/gitlab/ci/input/arguments/unknown.rb +++ b/lib/gitlab/ci/input/arguments/unknown.rb @@ -10,7 +10,11 @@ module Arguments # class Unknown < Input::Arguments::Base def validate! - error("unrecognized input argument specification: `#{spec.each_key.first}`") + if spec.is_a?(Hash) + error("unrecognized input argument specification: `#{spec.each_key.first}`") + else + error('unrecognized input argument definition') + end end def to_value diff --git a/spec/lib/gitlab/ci/input/arguments/unknown_spec.rb b/spec/lib/gitlab/ci/input/arguments/unknown_spec.rb new file mode 100644 index 00000000000000..1270423ac720fe --- /dev/null +++ b/spec/lib/gitlab/ci/input/arguments/unknown_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Input::Arguments::Unknown, feature_category: :pipeline_composition do + it 'raises an error when someone tries to evaluate the value' do + argument = described_class.new(:website, nil, 'https://example.gitlab.com') + + expect(argument).not_to be_valid + expect { argument.to_value }.to raise_error ArgumentError + end + + describe '.matches?' do + it 'always matches' do + expect(described_class.matches?('abc')).to be true + end + end +end -- GitLab From 9602f5bd922b5661c667435914dbabcdba669f7a Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 28 Feb 2023 13:36:28 +0100 Subject: [PATCH 14/15] Add more specs around unknown CI input arguments --- lib/gitlab/ci/input/arguments/unknown.rb | 2 +- lib/gitlab/ci/input/inputs.rb | 2 +- spec/lib/gitlab/ci/input/inputs_spec.rb | 35 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/ci/input/arguments/unknown.rb b/lib/gitlab/ci/input/arguments/unknown.rb index d3c85e30e7f88e..5873e6e66a6a0a 100644 --- a/lib/gitlab/ci/input/arguments/unknown.rb +++ b/lib/gitlab/ci/input/arguments/unknown.rb @@ -10,7 +10,7 @@ module Arguments # class Unknown < Input::Arguments::Base def validate! - if spec.is_a?(Hash) + if spec.is_a?(Hash) && spec.count == 1 error("unrecognized input argument specification: `#{spec.each_key.first}`") else error('unrecognized input argument definition') diff --git a/lib/gitlab/ci/input/inputs.rb b/lib/gitlab/ci/input/inputs.rb index df1eb49a89e14d..743ae2ecf1eb45 100644 --- a/lib/gitlab/ci/input/inputs.rb +++ b/lib/gitlab/ci/input/inputs.rb @@ -55,7 +55,7 @@ def to_hash private def validate! - @errors.push("unspecified input arguments: #{unknown.inspect}") if unknown.any? + @errors.push("unknown input arguments: #{unknown.inspect}") if unknown.any? end def fabricate! diff --git a/spec/lib/gitlab/ci/input/inputs_spec.rb b/spec/lib/gitlab/ci/input/inputs_spec.rb index 2abf0315aca872..5d2d519229900f 100644 --- a/spec/lib/gitlab/ci/input/inputs_spec.rb +++ b/spec/lib/gitlab/ci/input/inputs_spec.rb @@ -88,4 +88,39 @@ expect(inputs.errors.first).to eq '`test` input: unrecognized input argument specification: `unknown`' end end + + context 'when unknown arguments are being passed by a user' do + let(:spec) do + { env: { default: 'development' } } + end + + let(:args) { { website: 'https://gitlab.com', run: 'tests' } } + + it 'returns an error with a list of unknown arguments' do + inputs = described_class.new(spec, args) + + expect(inputs).not_to be_valid + expect(inputs.errors.first).to eq 'unknown input arguments: [:website, :run]' + end + end + + context 'when composite specification is being used' do + let(:spec) do + { + env: { + default: 'dev', + options: %w[test dev prod] + } + } + end + + let(:args) { { env: 'dev' } } + + it 'returns an error describing an unknown specification' do + inputs = described_class.new(spec, args) + + expect(inputs).not_to be_valid + expect(inputs.errors.first).to eq '`env` input: unrecognized input argument definition' + end + end end -- GitLab From 10ad126bf3087a3bbdf4689d073280cde7764a7e Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 28 Feb 2023 13:47:25 +0100 Subject: [PATCH 15/15] Clarify what spec is valid for required CI input argument --- lib/gitlab/ci/input/arguments/required.rb | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/gitlab/ci/input/arguments/required.rb b/lib/gitlab/ci/input/arguments/required.rb index 87e8441b92adde..b4e218ed29ea29 100644 --- a/lib/gitlab/ci/input/arguments/required.rb +++ b/lib/gitlab/ci/input/arguments/required.rb @@ -9,7 +9,7 @@ module Arguments # class Required < Input::Arguments::Base ## - # The value has to be define, but it may be empty. + # The value has to be defined, but it may be empty. # def validate! error('required value has not been provided') if value.nil? @@ -20,19 +20,21 @@ def to_value end ## - # Required arguments does not have nested configuration. Following definitions both describe a required - # argument: - # - # An undefined value (similarly to how this is done in Ruby keyword argument: `def initialize(website:)`. + # Required arguments do not have nested configuration. It has to be defined a null value. # # ```yaml - # website: + # spec: + # inputs: + # website: # ``` # - # An empty value, that has no specification. It also describes a required argument. + # An empty value, that has no specification is also considered as a "required" input, however we should + # never see that being used, because it will be rejected by Ci::Config::Header validation. # # ```yaml - # website: "" + # spec: + # inputs: + # website: "" # ``` def self.matches?(spec) spec.to_s.empty? -- GitLab