Commit ef34b9d4 authored by Francisco Javier López's avatar Francisco Javier López Committed by Kamil Trzciński
Browse files

Update Web IDE config image object to accept a ports section

In order to implement https://gitlab.com/gitlab-org/gitlab-ee/issues/5276
we need several modifications on the CI config file.

We are adding a new ports section in the default Image object. Each
one of these ports will accept: number, protocol and name.

By default this new configuration will be only enabled in
the Web IDE config file.
parent c2b9adad
---
title: Update Web IDE config to accept ports
merge_request: 9818
author:
type: added
......@@ -11,7 +11,8 @@ module Gitlab
def initialize(config, opts = {})
@config = build_config(config, opts)
 
@global = Entry::Global.new(@config)
@global = Entry::Global.new(@config,
with_image_ports: true)
@global.compose!
rescue Gitlab::Config::Loader::FormatError => e
raise Config::ConfigError, e.message
......
......@@ -20,6 +20,7 @@ module Gitlab
 
validations do
validates :config, allowed_keys: ALLOWED_KEYS
validates :config, job_port_unique: { data: ->(record) { record.ports } }
 
with_options allow_nil: true do
validates :tags, array_of_strings: true
......
......@@ -50,6 +50,26 @@ describe Ci::CreateWebIdeTerminalService do
 
it_behaves_like 'be successful'
end
context 'for configuration with ports' do
let(:config_content) do
<<-EOS
terminal:
image:
name: ruby:2.2
ports:
- 80
script: rspec
services:
- name: test
alias: test
ports:
- 8080
EOS
end
it_behaves_like 'be successful'
end
end
end
 
......
......@@ -3,7 +3,7 @@
require 'spec_helper'
 
describe Gitlab::WebIde::Config::Entry::Terminal do
let(:entry) { described_class.new(config) }
let(:entry) { described_class.new(config, with_image_ports: true) }
 
describe '.nodes' do
context 'when filtering all the entry/node names' do
......@@ -30,6 +30,21 @@ describe Gitlab::WebIde::Config::Entry::Terminal do
expect(entry).to be_valid
end
end
context 'when the same port is not duplicated' do
let(:config) do
{
image: { name: "ruby", ports: [80] },
services: [{ name: "mysql", alias: "service1", ports: [81] }, { name: "mysql", alias: "service2", ports: [82] }]
}
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
end
 
context 'when entry value is not correct' do
......@@ -63,6 +78,23 @@ describe Gitlab::WebIde::Config::Entry::Terminal do
end
end
end
context 'when the same port is duplicated' do
let(:config) do
{
image: { name: "ruby", ports: [80] },
services: [{ name: "mysql", ports: [80] }, { name: "mysql", ports: [81] }]
}
end
describe '#valid?' do
it 'is invalid' do
expect(entry).not_to be_valid
expect(entry.errors.count).to eq 1
expect(entry.errors.first).to match "each port number can only be referenced once"
end
end
end
end
end
 
......@@ -80,10 +112,6 @@ describe Gitlab::WebIde::Config::Entry::Terminal do
end
 
describe '#value' do
before do
entry.compose!
end
context 'when entry is correct' do
let(:config) do
{ before_script: %w[ls pwd],
......
......@@ -1389,8 +1389,13 @@ module API
expose :name, :script, :timeout, :when, :allow_failure
end
 
class Port < Grape::Entity
expose :number, :protocol, :name
end
class Image < Grape::Entity
expose :name, :entrypoint
expose :ports, using: JobRequest::Port
end
 
class Service < Image
......
......@@ -4,7 +4,7 @@ module Gitlab
module Ci
module Build
class Image
attr_reader :alias, :command, :entrypoint, :name
attr_reader :alias, :command, :entrypoint, :name, :ports
 
class << self
def from_image(job)
......@@ -26,17 +26,25 @@ module Gitlab
def initialize(image)
if image.is_a?(String)
@name = image
@ports = []
elsif image.is_a?(Hash)
@alias = image[:alias]
@command = image[:command]
@entrypoint = image[:entrypoint]
@name = image[:name]
@ports = build_ports(image).select(&:valid?)
end
end
 
def valid?
@name.present?
end
private
def build_ports(image)
image[:ports].to_a.map { |port| ::Gitlab::Ci::Build::Port.new(port) }
end
end
end
end
......
# frozen_string_literal: true
module Gitlab
module Ci
module Build
class Port
DEFAULT_PORT_NAME = 'default_port'.freeze
DEFAULT_PORT_PROTOCOL = 'http'.freeze
attr_reader :number, :protocol, :name
def initialize(port)
@name = DEFAULT_PORT_NAME
@protocol = DEFAULT_PORT_PROTOCOL
case port
when Integer
@number = port
when Hash
@number = port[:number]
@protocol = port.fetch(:protocol, @protocol)
@name = port.fetch(:name, @name)
end
end
def valid?
@number.present?
end
end
end
end
end
......@@ -9,24 +9,24 @@ module Gitlab
#
class Image < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
include ::Gitlab::Config::Entry::Configurable
 
ALLOWED_KEYS = %i[name entrypoint].freeze
ALLOWED_KEYS = %i[name entrypoint ports].freeze
 
validations do
validates :config, hash_or_string: true
validates :config, allowed_keys: ALLOWED_KEYS
validates :config, disallowed_keys: %i[ports], unless: :with_image_ports?
 
validates :name, type: String, presence: true
validates :entrypoint, array_of_strings: true, allow_nil: true
end
 
def hash?
@config.is_a?(Hash)
end
entry :ports, Entry::Ports,
description: 'Ports used expose the image'
 
def string?
@config.is_a?(String)
end
attributes :ports
 
def name
value[:name]
......@@ -42,6 +42,14 @@ module Gitlab
 
{}
end
def with_image_ports?
opt(:with_image_ports)
end
def skip_config_hash_validation?
true
end
end
end
end
......
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents a configuration of an Image Port.
#
class Port < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
ALLOWED_KEYS = %i[number protocol name].freeze
validations do
validates :config, hash_or_integer: true
validates :config, allowed_keys: ALLOWED_KEYS
validates :number, type: Integer, presence: true
validates :protocol, type: String, inclusion: { in: %w[http https], message: 'should be http or https' }, allow_blank: true
validates :name, type: String, presence: false, allow_nil: true
end
def number
value[:number]
end
def protocol
value[:protocol]
end
def name
value[:name]
end
def value
return { number: @config } if integer?
return @config if hash?
{}
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Ci
class Config
module Entry
##
# Entry that represents a configuration of the ports of a Docker service.
#
class Ports < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
validations do
validates :config, type: Array
validates :config, port_name_present_and_unique: true
validates :config, port_unique: true
end
def compose!(deps = nil)
super do
@entries = []
@config.each do |config|
@entries << ::Gitlab::Config::Entry::Factory.new(Entry::Port)
.value(config || {})
.with(key: "port", parent: self, description: "port definition.") # rubocop:disable CodeReuse/ActiveRecord
.create!
end
@entries.each do |entry|
entry.compose!(deps)
end
end
end
def value
@entries.map(&:value)
end
def descendants
@entries
end
end
end
end
end
end
......@@ -10,16 +10,18 @@ module Gitlab
class Service < Image
include ::Gitlab::Config::Entry::Validatable
 
ALLOWED_KEYS = %i[name entrypoint command alias].freeze
ALLOWED_KEYS = %i[name entrypoint command alias ports].freeze
 
validations do
validates :config, hash_or_string: true
validates :config, allowed_keys: ALLOWED_KEYS
validates :config, disallowed_keys: %i[ports], unless: :with_image_ports?
 
validates :name, type: String, presence: true
validates :entrypoint, array_of_strings: true, allow_nil: true
validates :command, array_of_strings: true, allow_nil: true
validates :alias, type: String, allow_nil: true
validates :alias, type: String, presence: true, unless: ->(record) { record.ports.blank? }
end
 
def alias
......
......@@ -12,6 +12,7 @@ module Gitlab
 
validations do
validates :config, type: Array
validates :config, services_with_ports_alias_unique: true, if: ->(record) { record.opt(:with_image_ports) }
end
 
def compose!(deps = nil)
......@@ -20,6 +21,7 @@ module Gitlab
@config.each do |config|
@entries << ::Gitlab::Config::Entry::Factory.new(Entry::Service)
.value(config || {})
.with(key: "service", parent: self, description: "service definition.") # rubocop:disable CodeReuse/ActiveRecord
.create!
end
 
......
......@@ -21,7 +21,7 @@ module Gitlab
include Validatable
 
validations do
validates :config, type: Hash
validates :config, type: Hash, unless: :skip_config_hash_validation?
end
end
 
......@@ -30,6 +30,10 @@ module Gitlab
return unless valid?
 
self.class.nodes.each do |key, factory|
# If we override the config type validation
# we can end with different config types like String
next unless config.is_a?(Hash)
factory
.value(config[key])
.with(key: key, parent: self)
......@@ -45,6 +49,10 @@ module Gitlab
end
# rubocop: enable CodeReuse/ActiveRecord
 
def skip_config_hash_validation?
false
end
class_methods do
def nodes
Hash[(@nodes || {}).map { |key, factory| [key, factory.dup] }]
......
......@@ -61,7 +61,7 @@ module Gitlab
end
 
def fabricate(entry, value = nil)
entry.new(value, @metadata).tap do |node|
entry.new(value, @metadata) do |node|
node.key = @attributes[:key]
node.parent = @attributes[:parent]
node.default = @attributes[:default]
......
......@@ -17,6 +17,8 @@ module Gitlab
@metadata = metadata
@entries = {}
 
yield(self) if block_given?
self.class.aspects.to_a.each do |aspect|
instance_exec(&aspect)
end
......@@ -44,6 +46,12 @@ module Gitlab
@parent ? @parent.ancestors + [@parent] : []
end
 
def opt(key)
opt = metadata[key]
opt = @parent.opt(key) if opt.nil? && @parent
opt
end
def valid?
errors.none?
end
......@@ -85,6 +93,18 @@ module Gitlab
"#<#{self.class.name} #{unspecified}{#{key}: #{val.inspect}}>"
end
 
def hash?
@config.is_a?(Hash)
end
def string?
@config.is_a?(String)
end
def integer?
@config.is_a?(Integer)
end
def self.default(**)
end
 
......
......@@ -19,7 +19,10 @@ module Gitlab
 
entry = self.class.entry_class(strategy)
 
super(@subject = entry.new(config, metadata))
@subject = entry.new(config, metadata)
yield(@subject) if block_given?
super(@subject)
end
 
def self.strategy(name, **opts)
......
......@@ -15,6 +15,17 @@ module Gitlab
end
end
 
class DisallowedKeysValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
present_keys = value.try(:keys).to_a & options[:in]
if present_keys.any?
record.errors.add(attribute, "contains disallowed keys: " +
present_keys.join(', '))
end
end
end
class AllowedValuesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless options[:in].include?(value.to_s)
......@@ -186,6 +197,97 @@ module Gitlab
end
end
end
class PortNamePresentAndUniqueValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return unless value.is_a?(Array)
ports_size = value.count
return if ports_size <= 1
named_ports = value.select { |e| e.is_a?(Hash) }.map { |e| e[:name] }.compact.map(&:downcase)
if ports_size != named_ports.size
record.errors.add(attribute, 'when there is more than one port, a unique name should be added')
end
if ports_size != named_ports.uniq.size
record.errors.add(attribute, 'each port name must be different')
end
end
end
class PortUniqueValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
value = ports(value)
return unless value.is_a?(Array)
ports_size = value.count
return if ports_size <= 1
if transform_ports(value).size != ports_size
record.errors.add(attribute, 'each port number can only be referenced once')
end
end
private
def ports(current_data)
current_data
end
def transform_ports(raw_ports)
raw_ports.map do |port|
case port
when Integer
port
when Hash
port[:number]
end
end.uniq
end
end
class JobPortUniqueValidator < PortUniqueValidator
private
def ports(current_data)
return unless current_data.is_a?(Hash)
(image_ports(current_data) + services_ports(current_data)).compact
end
def image_ports(current_data)
return [] unless current_data[:image].is_a?(Hash)
current_data.dig(:image, :ports).to_a
end
def services_ports(current_data)
current_data.dig(:services).to_a.flat_map { |service| service.is_a?(Hash) ? service[:ports] : nil }
end
end
class ServicesWithPortsAliasUniqueValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
current_aliases = aliases(value)
return if current_aliases.empty?
unless aliases_unique?(current_aliases)
record.errors.add(:config, 'alias must be unique in services with ports')
end
end
private
def aliases(value)
value.select { |s| s.is_a?(Hash) && s[:ports] }.pluck(:alias) # rubocop:disable CodeReuse/ActiveRecord
end
def aliases_unique?(aliases)
aliases.size == aliases.uniq.size
end
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
describe API::Entities::JobRequest::Image do
let(:ports) { [{ number: 80, protocol: 'http', name: 'name' }]}
let(:image) { double(name: 'image_name', entrypoint: ['foo'], ports: ports)}
let(:entity) { described_class.new(image) }
subject { entity.as_json }
it 'returns the image name' do
expect(subject[:name]).to eq 'image_name'
end
it 'returns the entrypoint' do
expect(subject[:entrypoint]).to eq ['foo']