diff --git a/lib/gitlab/ci/components/header.rb b/lib/gitlab/ci/components/header.rb new file mode 100644 index 0000000000000000000000000000000000000000..732874d7a8832c91571de922bc359d63e2e44226 --- /dev/null +++ b/lib/gitlab/ci/components/header.rb @@ -0,0 +1,42 @@ +# 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 + attr_reader :errors + + def initialize(header) + @header = header + @errors = [] + end + + def empty? + inputs_spec.to_h.empty? + end + + def inputs(args) + @input ||= Ci::Input::Inputs.new(inputs_spec, args) + end + + def context(args) + inputs(args).then do |input| + raise ArgumentError unless input.valid? + + Ci::Interpolation::Context.new({ inputs: input.to_hash }) + end + end + + private + + def inputs_spec + @header.dig(:spec, :inputs) + end + end + end + end +end diff --git a/lib/gitlab/ci/input/arguments/base.rb b/lib/gitlab/ci/input/arguments/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..a46037c40cea2e501e5c0dac4c597e0fd53fa94d --- /dev/null +++ b/lib/gitlab/ci/input/arguments/base.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Input + module Arguments + ## + # Input::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/input/arguments/default.rb b/lib/gitlab/ci/input/arguments/default.rb new file mode 100644 index 0000000000000000000000000000000000000000..fd61c1ab78672e26c5644069fe96308dd9370ef6 --- /dev/null +++ b/lib/gitlab/ci/input/arguments/default.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Input + module Arguments + ## + # Input::Arguments::Default class represents user-provided input argument that has a default value. + # + class Default < Input::Arguments::Base + 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.nil? ? 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/input/arguments/options.rb b/lib/gitlab/ci/input/arguments/options.rb new file mode 100644 index 0000000000000000000000000000000000000000..debc89b10bd8f6f178a4feaf7fafce44c14d4137 --- /dev/null +++ b/lib/gitlab/ci/input/arguments/options.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Input + module Arguments + ## + # 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 < Input::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.nil? + error("argument value #{value} not allowlisted") unless options.include?(value) + else + error('argument not provided') + end + 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/input/arguments/required.rb b/lib/gitlab/ci/input/arguments/required.rb new file mode 100644 index 0000000000000000000000000000000000000000..b4e218ed29ea291970c71a6f0cbebf5c2b5c414d --- /dev/null +++ b/lib/gitlab/ci/input/arguments/required.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Input + module Arguments + ## + # Input::Arguments::Required class represents user-provided required input argument. + # + class Required < Input::Arguments::Base + ## + # The value has to be defined, but it may be empty. + # + def validate! + error('required value has not been provided') if value.nil? + end + + def to_value + value + end + + ## + # Required arguments do not have nested configuration. It has to be defined a null value. + # + # ```yaml + # spec: + # inputs: + # website: + # ``` + # + # 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 + # spec: + # inputs: + # website: "" + # ``` + def self.matches?(spec) + spec.to_s.empty? + end + end + end + end + end +end diff --git a/lib/gitlab/ci/input/arguments/unknown.rb b/lib/gitlab/ci/input/arguments/unknown.rb new file mode 100644 index 0000000000000000000000000000000000000000..5873e6e66a6a0afb3b9ba4db2dfbf856b3151807 --- /dev/null +++ b/lib/gitlab/ci/input/arguments/unknown.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Input + module Arguments + ## + # 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 < Input::Arguments::Base + def validate! + if spec.is_a?(Hash) && spec.count == 1 + error("unrecognized input argument specification: `#{spec.each_key.first}`") + else + error('unrecognized input argument definition') + end + 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/input/inputs.rb b/lib/gitlab/ci/input/inputs.rb new file mode 100644 index 0000000000000000000000000000000000000000..743ae2ecf1eb456973677d8d4c70b4bc4c795cb8 --- /dev/null +++ b/lib/gitlab/ci/input/inputs.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + 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 Inputs + UnknownSpecArgumentError = Class.new(StandardError) + + ARGUMENTS = [ + 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) + @spec = spec + @args = args + @inputs = [] + @errors = [] + + validate! + fabricate! + end + + 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 + + def to_hash + @inputs.inject({}) do |hash, argument| + raise ArgumentError unless argument.valid? + + hash.merge(argument.to_hash) + end + end + + private + + def validate! + @errors.push("unknown 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/header_spec.rb b/spec/lib/gitlab/ci/components/header_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b1af4ca92388bb3273ba03c5e64657d857396b0d --- /dev/null +++ b/spec/lib/gitlab/ci/components/header_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Components::Header, feature_category: :pipeline_composition 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).not_to be_empty + end + + describe '#inputs' do + it 'fabricates input data' do + input = subject.inputs({ website: 'https//gitlab.com', run: 'opt1' }) + + 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 empty header' do + expect(subject).to be_empty + end + end +end diff --git a/spec/lib/gitlab/ci/input/arguments/base_spec.rb b/spec/lib/gitlab/ci/input/arguments/base_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed8e99b72571b018ac442935d6cae62a887cf529 --- /dev/null +++ b/spec/lib/gitlab/ci/input/arguments/base_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Input::Arguments::Base, feature_category: :pipeline_composition 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/input/arguments/default_spec.rb b/spec/lib/gitlab/ci/input/arguments/default_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6b5dd441eb7004c4b3124c01c3daca71acdcd209 --- /dev/null +++ b/spec/lib/gitlab/ci/input/arguments/default_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +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') + + 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 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 '' + expect(argument.to_hash).to eq({ website: '' }) + 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 + + 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/input/arguments/options_spec.rb b/spec/lib/gitlab/ci/input/arguments/options_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..afa279ad48d279b8c0c35991a2129dc064742f5d --- /dev/null +++ b/spec/lib/gitlab/ci/input/arguments/options_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +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') + + 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 + 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 + 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/input/arguments/required_spec.rb b/spec/lib/gitlab/ci/input/arguments/required_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0c2ffc282eaba962d4580eaab61c86a1487661a3 --- /dev/null +++ b/spec/lib/gitlab/ci/input/arguments/required_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +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') + + 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 empty value if user-provider value is empty' do + argument = described_class.new(:website, nil, '') + + expect(argument).to be_valid + expect(argument.to_hash).to eq(website: '') + end + + 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 + it 'matches specs without configuration' do + 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 + end +end 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 0000000000000000000000000000000000000000..1270423ac720fea5faad9ddf3dd34ae15549ee10 --- /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 diff --git a/spec/lib/gitlab/ci/input/inputs_spec.rb b/spec/lib/gitlab/ci/input/inputs_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5d2d519229900fe19d23d621ed5509f5b4954955 --- /dev/null +++ b/spec/lib/gitlab/ci/input/inputs_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::Ci::Input::Inputs, feature_category: :pipeline_composition do + describe '#valid?' do + let(:spec) { { website: nil } } + + it 'describes user-provided inputs' do + inputs = described_class.new(spec, { website: 'http://example.gitlab.com' }) + + expect(inputs).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 + inputs = described_class.new(spec, args) + + 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 + inputs = described_class.new({}, {}) + + expect(inputs).to be_valid + expect(inputs.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 } } + + it 'returns an error' do + inputs = described_class.new(spec, {}) + + expect(inputs).not_to be_valid + expect(inputs.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 + inputs = described_class.new(spec, {}) + + expect(inputs).not_to be_valid + expect(inputs.errors.first).to eq '`run` input: argument not provided' + 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 + inputs = described_class.new(spec, args) + + 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 + + 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