Commit 64b1044e authored by Kamil Trzciński's avatar Kamil Trzciński
Browse files

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

This decouples Ci::Config to provide a common interface for handling
user configuration files.
parent 6775dafa
......@@ -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