Commit bd45beaf authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch 'generalize-ci-config' into 'master'

ci/config: generalize Config validation into Gitlab::Config:: module

See merge request gitlab-org/gitlab-ce!23443
parents 4e85011f 64b1044e
......@@ -15,7 +15,7 @@ module Gitlab
@global = Entry::Global.new(@config)
@global.compose!
rescue Loader::FormatError,
rescue Gitlab::Config::Loader::FormatError,
Extendable::ExtensionError,
External::Processor::IncludeError => e
raise Config::ConfigError, e.message
......@@ -71,7 +71,7 @@ module Gitlab
private
def build_config(config, opts = {})
initial_config = Loader.new(config).load!
initial_config = Gitlab::Config::Loader::Yaml.new(config).load!
project = opts.fetch(:project, nil)
if project
......
......@@ -7,10 +7,10 @@ module Gitlab
##
# Entry that represents a configuration of job artifacts.
#
class Artifacts < Node
include Configurable
include Validatable
include Attributable
class Artifacts < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[name untracked paths reports when expire_in].freeze
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module Attributable
extend ActiveSupport::Concern
class_methods do
def attributes(*attributes)
attributes.flatten.each do |attribute|
if method_defined?(attribute)
raise ArgumentError, 'Method already defined!'
end
define_method(attribute) do
return unless config.is_a?(Hash)
config[attribute]
end
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents a boolean value.
#
class Boolean < Node
include Validatable
validations do
validates :config, boolean: true
end
end
end
end
end
end
......@@ -7,9 +7,9 @@ module Gitlab
##
# Entry that represents a cache configuration
#
class Cache < Node
include Configurable
include Attributable
class Cache < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[key untracked paths policy].freeze
DEFAULT_POLICY = 'pull-push'.freeze
......@@ -22,7 +22,7 @@ module Gitlab
entry :key, Entry::Key,
description: 'Cache key used to define a cache affinity.'
entry :untracked, Entry::Boolean,
entry :untracked, ::Gitlab::Config::Entry::Boolean,
description: 'Cache all untracked files.'
entry :paths, Entry::Paths,
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a job script.
#
class Commands < Node
include Validatable
class Commands < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings_or_string: true
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# This mixin is responsible for adding DSL, which purpose is to
# simplifly process of adding child nodes.
#
# This can be used only if parent node is a configuration entry that
# holds a hash as a configuration value, for example:
#
# job:
# script: ...
# artifacts: ...
#
module Configurable
extend ActiveSupport::Concern
included do
include Validatable
validations do
validates :config, type: Hash
end
end
# rubocop: disable CodeReuse/ActiveRecord
def compose!(deps = nil)
return unless valid?
self.class.nodes.each do |key, factory|
factory
.value(config[key])
.with(key: key, parent: self)
entries[key] = factory.create!
end
yield if block_given?
entries.each_value do |entry|
entry.compose!(deps)
end
end
# rubocop: enable CodeReuse/ActiveRecord
class_methods do
def nodes
Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }]
end
private
# rubocop: disable CodeReuse/ActiveRecord
def entry(key, entry, metadata)
factory = Entry::Factory.new(entry)
.with(description: metadata[:description])
(@nodes ||= {}).merge!(key.to_sym => factory)
end
# rubocop: enable CodeReuse/ActiveRecord
def helpers(*nodes)
nodes.each do |symbol|
define_method("#{symbol}_defined?") do
entries[symbol]&.specified?
end
define_method("#{symbol}_value") do
return unless entries[symbol] && entries[symbol].valid?
entries[symbol].value
end
end
end
end
end
end
end
end
end
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents Coverage settings.
#
class Coverage < Node
include Validatable
class Coverage < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, regexp: true
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents an environment.
#
class Environment < Node
include Validatable
class Environment < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name url action on_stop].freeze
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Factory class responsible for fabricating entry objects.
#
class Factory
InvalidFactory = Class.new(StandardError)
def initialize(entry)
@entry = entry
@metadata = {}
@attributes = {}
end
def value(value)
@value = value
self
end
def metadata(metadata)
@metadata.merge!(metadata)
self
end
def with(attributes)
@attributes.merge!(attributes)
self
end
def create!
raise InvalidFactory unless defined?(@value)
##
# We assume that unspecified entry is undefined.
# See issue #18775.
#
if @value.nil?
Entry::Unspecified.new(
fabricate_unspecified
)
else
fabricate(@entry, @value)
end
end
private
def fabricate_unspecified
##
# If entry has a default value we fabricate concrete node
# with default value.
#
if @entry.default.nil?
fabricate(Entry::Undefined)
else
fabricate(@entry, @entry.default)
end
end
def fabricate(entry, value = nil)
entry.new(value, @metadata).tap do |node|
node.key = @attributes[:key]
node.parent = @attributes[:parent]
node.description = @attributes[:description]
end
end
end
end
end
end
end
......@@ -8,8 +8,8 @@ module Gitlab
# This class represents a global entry - root Entry for entire
# GitLab CI Configuration file.
#
class Global < Node
include Configurable
class Global < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
entry :before_script, Entry::Script,
description: 'Script that will be executed before each job.'
......@@ -49,7 +49,7 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def compose_jobs!
factory = Entry::Factory.new(Entry::Jobs)
factory = ::Gitlab::Config::Entry::Factory.new(Entry::Jobs)
.value(@config.except(*self.class.nodes.keys))
.with(key: :jobs, parent: self,
description: 'Jobs definition for this pipeline')
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a hidden CI/CD key.
#
class Hidden < Node
include Validatable
class Hidden < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, presence: true
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a Docker image.
#
class Image < Node
include Validatable
class Image < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name entrypoint].freeze
......
......@@ -7,9 +7,9 @@ module Gitlab
##
# Entry that represents a concrete CI/CD job.
#
class Job < Node
include Configurable
include Attributable
class Job < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[tags script only except type image services
allow_failure type stage when start_in artifacts cache
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a set of jobs.
#
class Jobs < Node
include Validatable
class Jobs < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: Hash
......@@ -34,7 +34,7 @@ module Gitlab
@config.each do |name, config|
node = hidden?(name) ? Entry::Hidden : Entry::Job
factory = Entry::Factory.new(node)
factory = ::Gitlab::Config::Entry::Factory.new(node)
.value(config || {})
.metadata(name: name)
.with(key: name, parent: self,
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a key.
#
class Key < Node
include Validatable
class Key < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, key: true
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module LegacyValidationHelpers
private
def validate_duration(value)
value.is_a?(String) && ChronicDuration.parse(value)
rescue ChronicDuration::DurationParseError
false
end
def validate_duration_limit(value, limit)
return false unless value.is_a?(String)
ChronicDuration.parse(value).second.from_now <
ChronicDuration.parse(limit).second.from_now
rescue ChronicDuration::DurationParseError
false
end
def validate_array_of_strings(values)
values.is_a?(Array) && values.all? { |value| validate_string(value) }
end
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all? { |value| validate_string_or_regexp(value) }
end
def validate_variables(variables)
variables.is_a?(Hash) &&
variables.flatten.all? do |value|
validate_string(value) || validate_integer(value)
end
end
def validate_integer(value)
value.is_a?(Integer)
end
def validate_string(value)
value.is_a?(String) || value.is_a?(Symbol)
end
def validate_regexp(value)
!value.nil? && Regexp.new(value.to_s) && true
rescue RegexpError, TypeError
false
end
def validate_string_or_regexp(value)
return true if value.is_a?(Symbol)
return false unless value.is_a?(String)
if value.first == '/' && value.last == '/'
validate_regexp(value[1...-1])
else
true
end
end
def validate_boolean(value)
value.in?([true, false])
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Base abstract class for each configuration entry node.
#
class Node
InvalidError = Class.new(StandardError)
attr_reader :config, :metadata
attr_accessor :key, :parent, :description
def initialize(config, **metadata)
@config = config
@metadata = metadata
@entries = {}
self.class.aspects.to_a.each do |aspect|
instance_exec(&aspect)
end
end
def [](key)
@entries[key] || Entry::Undefined.new
end
def compose!(deps = nil)
return unless valid?
yield if block_given?
end
def leaf?
@entries.none?
end
def descendants
@entries.values
end
def ancestors
@parent ? @parent.ancestors + [@parent] : []
end
def valid?
errors.none?
end
def errors
[]
end
def value
if leaf?
@config
else
meaningful = @entries.select do |_key, value|
value.specified? && value.relevant?
end
Hash[meaningful.map { |key, entry| [key, entry.value] }]
end
end
def specified?
true
end
def relevant?
true
end
def location
name = @key.presence || self.class.name.to_s.demodulize
.underscore.humanize.downcase
ancestors.map(&:key).append(name).compact.join(':')
end
def inspect
val = leaf? ? config : descendants
unspecified = specified? ? '' : '(unspecified) '
"#<#{self.class.name} #{unspecified}{#{key}: #{val.inspect}}>"
end
def self.default
end
def self.aspects
@aspects ||= []
end
private
attr_reader :entries
end
end
end
end
end
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents an array of paths.
#
class Paths < Node
include Validatable
class Paths < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings: true
......
......@@ -7,12 +7,12 @@ module Gitlab
##
# Entry that represents an only/except trigger policy for the job.
#
class Policy < Simplifiable
class Policy < ::Gitlab::Config::Entry::Simplifiable
strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) }
strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) }
class RefsPolicy < Entry::Node
include Entry::Validatable
class RefsPolicy < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings_or_regexps: true
......@@ -23,9 +23,9 @@ module Gitlab
end
end
class ComplexPolicy < Entry::Node
include Entry::Validatable
include Entry::Attributable
class ComplexPolicy < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[refs kubernetes variables changes].freeze
attributes :refs, :kubernetes, :variables, :changes
......@@ -58,7 +58,7 @@ module Gitlab
end
end
class UnknownStrategy < Entry::Node
class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors
["#{location} has to be either an array of conditions or a hash"]
end
......
......@@ -7,9 +7,9 @@ module Gitlab
##
# Entry that represents a configuration of job artifacts.
#
class Reports < Node
include Validatable
include Attributable
class Reports < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management].freeze
......
......@@ -7,12 +7,12 @@ module Gitlab
##
# Entry that represents a retry config for a job.
#
class Retry < Simplifiable
class Retry < ::Gitlab::Config::Entry::Simplifiable
strategy :SimpleRetry, if: -> (config) { config.is_a?(Integer) }
strategy :FullRetry, if: -> (config) { config.is_a?(Hash) }
class SimpleRetry < Entry::Node
include Entry::Validatable
class SimpleRetry < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, numericality: { only_integer: true,
......@@ -31,9 +31,9 @@ module Gitlab
end
end
class FullRetry < Entry::Node
include Entry::Validatable
include Entry::Attributable
class FullRetry < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[max when].freeze
attributes :max, :when
......@@ -73,7 +73,7 @@ module Gitlab
end
end
class UnknownStrategy < Entry::Node
class UnknownStrategy < ::Gitlab::Config::Entry::Node
def errors
["#{location} has to be either an integer or a hash"]
end
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a script.
#
class Script < Node
include Validatable
class Script < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings: true
......
......@@ -8,7 +8,7 @@ module Gitlab
# Entry that represents a configuration of Docker service.
#
class Service < Image
include Validatable
include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[name entrypoint command alias].freeze
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a configuration of Docker services.
#
class Services < Node
include Validatable
class Services < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: Array
......@@ -18,7 +18,7 @@ module Gitlab
super do
@entries = []
@config.each do |config|
@entries << Entry::Factory.new(Entry::Service)
@entries << ::Gitlab::Config::Entry::Factory.new(Entry::Service)
.value(config || {})
.create!
end
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
class Simplifiable < SimpleDelegator
EntryStrategy = Struct.new(:name, :condition)
def initialize(config, **metadata)
unless self.class.const_defined?(:UnknownStrategy)
raise ArgumentError, 'UndefinedStrategy not available!'
end
strategy = self.class.strategies.find do |variant|
variant.condition.call(config)
end
entry = self.class.entry_class(strategy)
super(entry.new(config, metadata))
end
def self.strategy(name, **opts)
EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy|
strategies.append(strategy)
end
end
def self.strategies
@strategies ||= []
end
def self.entry_class(strategy)
if strategy.present?
self.const_get(strategy.name)
else
self::UnknownStrategy
end
end
end
end
end
end
end
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a stage for a job.
#
class Stage < Node
include Validatable
class Stage < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: String
......
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents a configuration for pipeline stages.
#
class Stages < Node
include Validatable
class Stages < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings: true
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# This class represents an undefined entry.
#
class Undefined < Node
def initialize(*)
super(nil)
end
def value
nil
end
def valid?
true
end
def errors
[]
end
def specified?
false
end
def relevant?
false
end
def inspect
"#<#{self.class.name}>"
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# This class represents an unspecified entry.
#
# It decorates original entry adding method that indicates it is
# unspecified.
#
class Unspecified < SimpleDelegator
def specified?
false
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module Validatable
extend ActiveSupport::Concern
def self.included(node)
node.aspects.append -> do
@validator = self.class.validator.new(self)
@validator.validate(:new)
end
end
def errors
@validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
class_methods do
def validator
@validator ||= Class.new(Entry::Validator).tap do |validator|
if defined?(@validations)
@validations.each { |rules| validator.class_eval(&rules) }
end
end
end
private
def validations(&block)
(@validations ||= []).append(block)
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
class Validator < SimpleDelegator
include ActiveModel::Validations
include Entry::Validators
def initialize(entry)
super(entry)
end
def messages
errors.full_messages.map do |error|
"#{location} #{error}".downcase
end
end
def self.name
'Validator'
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
module Validators
class AllowedKeysValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unknown_keys = value.try(:keys).to_a - options[:in]
if unknown_keys.any?
record.errors.add(attribute, "contains unknown keys: " +
unknown_keys.join(', '))
end
end
end
class AllowedValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless options[:in].include?(value.to_s)
record.errors.add(attribute, "unknown value: #{value}")
end
end
end
class AllowedArrayValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unkown_values = value - options[:in]
unless unkown_values.empty?
record.errors.add(attribute, "contains unknown values: " +
unkown_values.join(', '))
end
end
end
class ArrayOfStringsValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_array_of_strings(value)
record.errors.add(attribute, 'should be an array of strings')
end
end
end
class BooleanValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_boolean(value)
record.errors.add(attribute, 'should be a boolean value')
end
end
end
class DurationValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_duration(value)
record.errors.add(attribute, 'should be a duration')
end
if options[:limit]
unless validate_duration_limit(value, options[:limit])
record.errors.add(attribute, 'should not exceed the limit')
end
end
end
end
class HashOrStringValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(String)
record.errors.add(attribute, 'should be a hash or a string')
end
end
end
class HashOrIntegerValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(Integer)
record.errors.add(attribute, 'should be a hash or an integer')
end
end
end
class KeyValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
if validate_string(value)
validate_path(record, attribute, value)
else
record.errors.add(attribute, 'should be a string or symbol')
end
end
private
def validate_path(record, attribute, value)
path = CGI.unescape(value.to_s)
if path.include?('/')
record.errors.add(attribute, 'cannot contain the "/" character')
elsif path == '.' || path == '..'
record.errors.add(attribute, 'cannot be "." or ".."')
end
end
end
class RegexpValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_regexp(value)
record.errors.add(attribute, 'must be a regular expression')
end
end
private
def look_like_regexp?(value)
value.is_a?(String) && value.start_with?('/') &&
value.end_with?('/')
end
def validate_regexp(value)
look_like_regexp?(value) &&
Regexp.new(value.to_s[1...-1]) &&
true
rescue RegexpError
false
end
end
class ArrayOfStringsOrRegexpsValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_regexps(value)
record.errors.add(attribute, 'should be an array of strings or regexps')
end
end
private
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all?(&method(:validate_string_or_regexp))
end
def validate_string_or_regexp(value)
return false unless value.is_a?(String)
return validate_regexp(value) if look_like_regexp?(value)
true
end
end
class ArrayOfStringsOrStringValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_string(value)
record.errors.add(attribute, 'should be an array of strings or a string')
end
end
private
def validate_array_of_strings_or_string(values)
validate_array_of_strings(values) || validate_string(values)
end
end
class TypeValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
type = options[:with]
raise unless type.is_a?(Class)
unless value.is_a?(type)
message = options[:message] || "should be a #{type.name}"
record.errors.add(attribute, message)
end
end
end
class VariablesValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_variables(value)
record.errors.add(attribute, 'should be a hash of key value pairs')
end
end
end
end
end
end
end
end
......@@ -7,8 +7,8 @@ module Gitlab
##
# Entry that represents environment variables.
#
class Variables < Node
include Validatable
class Variables < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, variables: true
......
......@@ -37,8 +37,8 @@ module Gitlab
end
def to_hash
@hash ||= Ci::Config::Loader.new(content).load!
rescue Ci::Config::Loader::FormatError
@hash ||= Gitlab::Config::Loader::Yaml.new(content).load!
rescue Gitlab::Config::Loader::FormatError
nil
end
......
......@@ -5,7 +5,7 @@ module Gitlab
class YamlProcessor
ValidationError = Class.new(StandardError)
include Gitlab::Ci::Config::Entry::LegacyValidationHelpers
include Gitlab::Config::Entry::LegacyValidationHelpers
attr_reader :cache, :stages, :jobs
......
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module Attributable
extend ActiveSupport::Concern
class_methods do
def attributes(*attributes)
attributes.flatten.each do |attribute|
if method_defined?(attribute)
raise ArgumentError, 'Method already defined!'
end
define_method(attribute) do
return unless config.is_a?(Hash)
config[attribute]
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# Entry that represents a boolean value.
#
class Boolean < Node
include Validatable
validations do
validates :config, boolean: true
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# This mixin is responsible for adding DSL, which purpose is to
# simplifly process of adding child nodes.
#
# This can be used only if parent node is a configuration entry that
# holds a hash as a configuration value, for example:
#
# job:
# script: ...
# artifacts: ...
#
module Configurable
extend ActiveSupport::Concern
included do
include Validatable
validations do
validates :config, type: Hash
end
end
# rubocop: disable CodeReuse/ActiveRecord
def compose!(deps = nil)
return unless valid?
self.class.nodes.each do |key, factory|
factory
.value(config[key])
.with(key: key, parent: self)
entries[key] = factory.create!
end
yield if block_given?
entries.each_value do |entry|
entry.compose!(deps)
end
end
# rubocop: enable CodeReuse/ActiveRecord
class_methods do
def nodes
Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }]
end
private
# rubocop: disable CodeReuse/ActiveRecord
def entry(key, entry, metadata)
factory = ::Gitlab::Config::Entry::Factory.new(entry)
.with(description: metadata[:description])
(@nodes ||= {}).merge!(key.to_sym => factory)
end
# rubocop: enable CodeReuse/ActiveRecord
def helpers(*nodes)
nodes.each do |symbol|
define_method("#{symbol}_defined?") do
entries[symbol]&.specified?
end
define_method("#{symbol}_value") do
return unless entries[symbol] && entries[symbol].valid?
entries[symbol].value
end
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# Factory class responsible for fabricating entry objects.
#
class Factory
InvalidFactory = Class.new(StandardError)
def initialize(entry)
@entry = entry
@metadata = {}
@attributes = {}
end
def value(value)
@value = value
self
end
def metadata(metadata)
@metadata.merge!(metadata)
self
end
def with(attributes)
@attributes.merge!(attributes)
self
end
def create!
raise InvalidFactory unless defined?(@value)
##
# We assume that unspecified entry is undefined.
# See issue #18775.
#
if @value.nil?
Entry::Unspecified.new(
fabricate_unspecified
)
else
fabricate(@entry, @value)
end
end
private
def fabricate_unspecified
##
# If entry has a default value we fabricate concrete node
# with default value.
#
if @entry.default.nil?
fabricate(Entry::Undefined)
else
fabricate(@entry, @entry.default)
end
end
def fabricate(entry, value = nil)
entry.new(value, @metadata).tap do |node|
node.key = @attributes[:key]
node.parent = @attributes[:parent]
node.description = @attributes[:description]
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module LegacyValidationHelpers
private
def validate_duration(value)
value.is_a?(String) && ChronicDuration.parse(value)
rescue ChronicDuration::DurationParseError
false
end
def validate_duration_limit(value, limit)
return false unless value.is_a?(String)
ChronicDuration.parse(value).second.from_now <
ChronicDuration.parse(limit).second.from_now
rescue ChronicDuration::DurationParseError
false
end
def validate_array_of_strings(values)
values.is_a?(Array) && values.all? { |value| validate_string(value) }
end
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all? { |value| validate_string_or_regexp(value) }
end
def validate_variables(variables)
variables.is_a?(Hash) &&
variables.flatten.all? do |value|
validate_string(value) || validate_integer(value)
end
end
def validate_integer(value)
value.is_a?(Integer)
end
def validate_string(value)
value.is_a?(String) || value.is_a?(Symbol)
end
def validate_regexp(value)
!value.nil? && Regexp.new(value.to_s) && true
rescue RegexpError, TypeError
false
end
def validate_string_or_regexp(value)
return true if value.is_a?(Symbol)
return false unless value.is_a?(String)
if value.first == '/' && value.last == '/'
validate_regexp(value[1...-1])
else
true
end
end
def validate_boolean(value)
value.in?([true, false])
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# Base abstract class for each configuration entry node.
#
class Node
InvalidError = Class.new(StandardError)
attr_reader :config, :metadata
attr_accessor :key, :parent, :description
def initialize(config, **metadata)
@config = config
@metadata = metadata
@entries = {}
self.class.aspects.to_a.each do |aspect|
instance_exec(&aspect)
end
end
def [](key)
@entries[key] || Entry::Undefined.new
end
def compose!(deps = nil)
return unless valid?
yield if block_given?
end
def leaf?
@entries.none?
end
def descendants
@entries.values
end
def ancestors
@parent ? @parent.ancestors + [@parent] : []
end
def valid?
errors.none?
end
def errors
[]
end
def value
if leaf?
@config
else
meaningful = @entries.select do |_key, value|
value.specified? && value.relevant?
end
Hash[meaningful.map { |key, entry| [key, entry.value] }]
end
end
def specified?
true
end
def relevant?
true
end
def location
name = @key.presence || self.class.name.to_s.demodulize
.underscore.humanize.downcase
ancestors.map(&:key).append(name).compact.join(':')
end
def inspect
val = leaf? ? config : descendants
unspecified = specified? ? '' : '(unspecified) '
"#<#{self.class.name} #{unspecified}{#{key}: #{val.inspect}}>"
end
def self.default
end
def self.aspects
@aspects ||= []
end
private
attr_reader :entries
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
class Simplifiable < SimpleDelegator
EntryStrategy = Struct.new(:name, :condition)
def initialize(config, **metadata)
unless self.class.const_defined?(:UnknownStrategy)
raise ArgumentError, 'UndefinedStrategy not available!'
end
strategy = self.class.strategies.find do |variant|
variant.condition.call(config)
end
entry = self.class.entry_class(strategy)
super(entry.new(config, metadata))
end
def self.strategy(name, **opts)
EntryStrategy.new(name, opts.fetch(:if)).tap do |strategy|
strategies.append(strategy)
end
end
def self.strategies
@strategies ||= []
end
def self.entry_class(strategy)
if strategy.present?
self.const_get(strategy.name)
else
self::UnknownStrategy
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# This class represents an undefined entry.
#
class Undefined < Node
def initialize(*)
super(nil)
end
def value
nil
end
def valid?
true
end
def errors
[]
end
def specified?
false
end
def relevant?
false
end
def inspect
"#<#{self.class.name}>"
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# This class represents an unspecified entry.
#
# It decorates original entry adding method that indicates it is
# unspecified.
#
class Unspecified < SimpleDelegator
def specified?
false
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module Validatable
extend ActiveSupport::Concern
def self.included(node)
node.aspects.append -> do
@validator = self.class.validator.new(self)
@validator.validate(:new)
end
end
def errors
@validator.messages + descendants.flat_map(&:errors) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
class_methods do
def validator
@validator ||= Class.new(Entry::Validator).tap do |validator|
if defined?(@validations)
@validations.each { |rules| validator.class_eval(&rules) }
end
end
end
private
def validations(&block)
(@validations ||= []).append(block)
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
class Validator < SimpleDelegator
include ActiveModel::Validations
include Entry::Validators
def initialize(entry)
super(entry)
end
def messages
errors.full_messages.map do |error|
"#{location} #{error}".downcase
end
end
def self.name
'Validator'
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Entry
module Validators
class AllowedKeysValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unknown_keys = value.try(:keys).to_a - options[:in]
if unknown_keys.any?
record.errors.add(attribute, "contains unknown keys: " +
unknown_keys.join(', '))
end
end
end
class AllowedValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless options[:in].include?(value.to_s)
record.errors.add(attribute, "unknown value: #{value}")
end
end
end
class AllowedArrayValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unkown_values = value - options[:in]
unless unkown_values.empty?
record.errors.add(attribute, "contains unknown values: " +
unkown_values.join(', '))
end
end
end
class ArrayOfStringsValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_array_of_strings(value)
record.errors.add(attribute, 'should be an array of strings')
end
end
end
class BooleanValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_boolean(value)
record.errors.add(attribute, 'should be a boolean value')
end
end
end
class DurationValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_duration(value)
record.errors.add(attribute, 'should be a duration')
end
if options[:limit]
unless validate_duration_limit(value, options[:limit])
record.errors.add(attribute, 'should not exceed the limit')
end
end
end
end
class HashOrStringValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(String)
record.errors.add(attribute, 'should be a hash or a string')
end
end
end
class HashOrIntegerValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.is_a?(Hash) || value.is_a?(Integer)
record.errors.add(attribute, 'should be a hash or an integer')
end
end
end
class KeyValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
if validate_string(value)
validate_path(record, attribute, value)
else
record.errors.add(attribute, 'should be a string or symbol')
end
end
private
def validate_path(record, attribute, value)
path = CGI.unescape(value.to_s)
if path.include?('/')
record.errors.add(attribute, 'cannot contain the "/" character')
elsif path == '.' || path == '..'
record.errors.add(attribute, 'cannot be "." or ".."')
end
end
end
class RegexpValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_regexp(value)
record.errors.add(attribute, 'must be a regular expression')
end
end
private
def look_like_regexp?(value)
value.is_a?(String) && value.start_with?('/') &&
value.end_with?('/')
end
def validate_regexp(value)
look_like_regexp?(value) &&
Regexp.new(value.to_s[1...-1]) &&
true
rescue RegexpError
false
end
end
class ArrayOfStringsOrRegexpsValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_regexps(value)
record.errors.add(attribute, 'should be an array of strings or regexps')
end
end
private
def validate_array_of_strings_or_regexps(values)
values.is_a?(Array) && values.all?(&method(:validate_string_or_regexp))
end
def validate_string_or_regexp(value)
return false unless value.is_a?(String)
return validate_regexp(value) if look_like_regexp?(value)
true
end
end
class ArrayOfStringsOrStringValidator < RegexpValidator
def validate_each(record, attribute, value)
unless validate_array_of_strings_or_string(value)
record.errors.add(attribute, 'should be an array of strings or a string')
end
end
private
def validate_array_of_strings_or_string(values)
validate_array_of_strings(values) || validate_string(values)
end
end
class TypeValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
type = options[:with]
raise unless type.is_a?(Class)
unless value.is_a?(type)
message = options[:message] || "should be a #{type.name}"
record.errors.add(attribute, message)
end
end
end
class VariablesValidator < ActiveModel::EachValidator
include LegacyValidationHelpers
def validate_each(record, attribute, value)
unless validate_variables(value)
record.errors.add(attribute, 'should be a hash of key value pairs')
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Config
module Loader
FormatError = Class.new(StandardError)
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
class Loader
FormatError = Class.new(StandardError)
module Config
module Loader
class Yaml
def initialize(config)
@config = YAML.safe_load(config, [Symbol], [], true)
rescue Psych::Exception => e
raise FormatError, e.message
raise Loader::FormatError, e.message
end
def valid?
......@@ -18,7 +16,7 @@ module Gitlab
def load!
unless valid?
raise FormatError, 'Invalid configuration format'
raise Loader::FormatError, 'Invalid configuration format'
end
@config.deep_symbolize_keys
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Attributable do
describe Gitlab::Config::Entry::Attributable do
let(:node) do
Class.new do
include Gitlab::Ci::Config::Entry::Attributable
include Gitlab::Config::Entry::Attributable
end
end
......@@ -48,7 +48,7 @@ describe Gitlab::Ci::Config::Entry::Attributable do
it 'raises an error' do
expectation = expect do
Class.new(String) do
include Gitlab::Ci::Config::Entry::Attributable
include Gitlab::Config::Entry::Attributable
attributes :length
end
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Boolean do
describe Gitlab::Config::Entry::Boolean do
let(:entry) { described_class.new(config) }
describe 'validations' do
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Configurable do
describe Gitlab::Config::Entry::Configurable do
let(:entry) do
Class.new(Gitlab::Ci::Config::Entry::Node) do
include Gitlab::Ci::Config::Entry::Configurable
Class.new(Gitlab::Config::Entry::Node) do
include Gitlab::Config::Entry::Configurable
end
end
......@@ -39,7 +39,7 @@ describe Gitlab::Ci::Config::Entry::Configurable do
it 'creates a node factory' do
expect(entry.nodes[:object])
.to be_an_instance_of Gitlab::Ci::Config::Entry::Factory
.to be_an_instance_of Gitlab::Config::Entry::Factory
end
it 'returns a duplicated factory object' do
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Factory do
describe Gitlab::Config::Entry::Factory do
describe '#create!' do
class Script < Gitlab::Config::Entry::Node
include Gitlab::Config::Entry::Validatable
validations do
validates :config, array_of_strings: true
end
end
let(:entry) { Script }
let(:factory) { described_class.new(entry) }
let(:entry) { Gitlab::Ci::Config::Entry::Script }
context 'when setting a concrete value' do
it 'creates entry with valid value' do
......@@ -54,7 +62,7 @@ describe Gitlab::Ci::Config::Entry::Factory do
context 'when not setting a value' do
it 'raises error' do
expect { factory.create! }.to raise_error(
Gitlab::Ci::Config::Entry::Factory::InvalidFactory
Gitlab::Config::Entry::Factory::InvalidFactory
)
end
end
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Simplifiable do
describe Gitlab::Config::Entry::Simplifiable do
describe '.strategy' do
let(:entry) do
Class.new(described_class) do
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Undefined do
describe Gitlab::Config::Entry::Undefined do
let(:entry) { described_class.new }
describe '#leaf?' do
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Unspecified do
describe Gitlab::Config::Entry::Unspecified do
let(:unspecified) { described_class.new(entry) }
let(:entry) { spy('Entry') }
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Validatable do
describe Gitlab::Config::Entry::Validatable do
let(:entry) do
Class.new(Gitlab::Ci::Config::Entry::Node) do
include Gitlab::Ci::Config::Entry::Validatable
Class.new(Gitlab::Config::Entry::Node) do
include Gitlab::Config::Entry::Validatable
end
end
......@@ -20,7 +20,7 @@ describe Gitlab::Ci::Config::Entry::Validatable do
it 'returns validator' do
expect(entry.validator.superclass)
.to be Gitlab::Ci::Config::Entry::Validator
.to be Gitlab::Config::Entry::Validator
end
it 'returns only one validator to mitigate leaks' do
......
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Validator do
describe Gitlab::Config::Entry::Validator do
let(:validator) { Class.new(described_class) }
let(:validator_instance) { validator.new(node) }
let(:node) { spy('node') }
......
require 'spec_helper'
describe Gitlab::Ci::Config::Loader do
describe Gitlab::Config::Loader::Yaml do
let(:loader) { described_class.new(yml) }
context 'when yaml syntax is correct' do
......@@ -31,7 +31,7 @@ describe Gitlab::Ci::Config::Loader do
describe '#load!' do
it 'raises error' do
expect { loader.load! }.to raise_error(
Gitlab::Ci::Config::Loader::FormatError,
Gitlab::Config::Loader::FormatError,
'Invalid configuration format'
)
end
......@@ -43,7 +43,7 @@ describe Gitlab::Ci::Config::Loader do
describe '#initialize' do
it 'raises FormatError' do
expect { loader }.to raise_error(Gitlab::Ci::Config::Loader::FormatError, 'Unknown alias: bad_alias')
expect { loader }.to raise_error(Gitlab::Config::Loader::FormatError, 'Unknown alias: bad_alias')
end
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment