Skip to content
Snippets Groups Projects
Commit 6ee1d8cf authored by Francisco Javier López's avatar Francisco Javier López Committed by Kamil Trzciński
Browse files

Add port section to CI Image object

In order to implement https://gitlab.com/gitlab-org/gitlab-ee/issues/10179
we need several modifications on the CI config file. We are
adding a new ports section in the default Image object.

Each 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 a7d3a5e4
No related branches found
No related tags found
No related merge requests found
Showing
with 682 additions and 18 deletions
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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)
Loading
Loading
@@ -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
Loading
Loading
# 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
Loading
Loading
@@ -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]
Loading
Loading
@@ -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
Loading
Loading
# 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
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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)
Loading
Loading
@@ -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
 
Loading
Loading
Loading
Loading
@@ -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
 
Loading
Loading
@@ -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)
Loading
Loading
@@ -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] }]
Loading
Loading
Loading
Loading
@@ -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]
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
@@ -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
Loading
Loading
@@ -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
 
Loading
Loading
Loading
Loading
@@ -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)
Loading
Loading
Loading
Loading
@@ -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)
Loading
Loading
@@ -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
Loading
Loading
# 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']
end
it 'returns the ports' do
expect(subject[:ports]).to eq ports
end
context 'when the ports param is nil' do
let(:ports) { nil }
it 'does not return the ports' do
expect(subject[:ports]).to be_nil
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ::API::Entities::JobRequest::Port do
let(:port) { double(number: 80, protocol: 'http', name: 'name')}
let(:entity) { described_class.new(port) }
subject { entity.as_json }
it 'returns the port number' do
expect(subject[:number]).to eq 80
end
it 'returns if the port protocol' do
expect(subject[:protocol]).to eq 'http'
end
it 'returns the port name' do
expect(subject[:name]).to eq 'name'
end
end
Loading
Loading
@@ -18,11 +18,16 @@ describe Gitlab::Ci::Build::Image do
it 'populates fabricated object with the proper name attribute' do
expect(subject.name).to eq(image_name)
end
it 'does not populate the ports' do
expect(subject.ports).to be_empty
end
end
 
context 'when image is defined as hash' do
let(:entrypoint) { '/bin/sh' }
let(:job) { create(:ci_build, options: { image: { name: image_name, entrypoint: entrypoint } } ) }
let(:job) { create(:ci_build, options: { image: { name: image_name, entrypoint: entrypoint, ports: [80] } } ) }
 
it 'fabricates an object of the proper class' do
is_expected.to be_kind_of(described_class)
Loading
Loading
@@ -32,6 +37,13 @@ describe Gitlab::Ci::Build::Image do
expect(subject.name).to eq(image_name)
expect(subject.entrypoint).to eq(entrypoint)
end
it 'populates the ports' do
port = subject.ports.first
expect(port.number).to eq 80
expect(port.protocol).to eq 'http'
expect(port.name).to eq 'default_port'
end
end
 
context 'when image name is empty' do
Loading
Loading
@@ -67,6 +79,10 @@ describe Gitlab::Ci::Build::Image do
expect(subject.first).to be_kind_of(described_class)
expect(subject.first.name).to eq(service_image_name)
end
it 'does not populate the ports' do
expect(subject.first.ports).to be_empty
end
end
 
context 'when service is defined as hash' do
Loading
Loading
@@ -75,7 +91,7 @@ describe Gitlab::Ci::Build::Image do
let(:service_command) { 'sleep 30' }
let(:job) do
create(:ci_build, options: { services: [{ name: service_image_name, entrypoint: service_entrypoint,
alias: service_alias, command: service_command }] })
alias: service_alias, command: service_command, ports: [80] }] })
end
 
it 'fabricates an non-empty array of objects' do
Loading
Loading
@@ -89,6 +105,11 @@ describe Gitlab::Ci::Build::Image do
expect(subject.first.entrypoint).to eq(service_entrypoint)
expect(subject.first.alias).to eq(service_alias)
expect(subject.first.command).to eq(service_command)
port = subject.first.ports.first
expect(port.number).to eq 80
expect(port.protocol).to eq 'http'
expect(port.name).to eq 'default_port'
end
end
 
Loading
Loading
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Build::Port do
subject { described_class.new(port) }
context 'when port is defined as an integer' do
let(:port) { 80 }
it 'populates the object' do
expect(subject.number).to eq 80
expect(subject.protocol).to eq described_class::DEFAULT_PORT_PROTOCOL
expect(subject.name).to eq described_class::DEFAULT_PORT_NAME
end
end
context 'when port is defined as hash' do
let(:port) { { number: 80, protocol: 'https', name: 'port_name' } }
it 'populates the object' do
expect(subject.number).to eq 80
expect(subject.protocol).to eq 'https'
expect(subject.name).to eq 'port_name'
end
end
end
Loading
Loading
@@ -35,6 +35,12 @@ describe Gitlab::Ci::Config::Entry::Image do
expect(entry.entrypoint).to be_nil
end
end
describe '#ports' do
it "returns image's ports" do
expect(entry.ports).to be_nil
end
end
end
 
context 'when configuration is a hash' do
Loading
Loading
@@ -69,6 +75,38 @@ describe Gitlab::Ci::Config::Entry::Image do
expect(entry.entrypoint).to eq %w(/bin/sh run)
end
end
context 'when configuration has ports' do
let(:ports) { [{ number: 80, protocol: 'http', name: 'foobar' }] }
let(:config) { { name: 'ruby:2.2', entrypoint: %w(/bin/sh run), ports: ports } }
let(:entry) { described_class.new(config, { with_image_ports: image_ports }) }
let(:image_ports) { false }
context 'when with_image_ports metadata is not enabled' do
describe '#valid?' do
it 'is not valid' do
expect(entry).not_to be_valid
expect(entry.errors).to include("image config contains disallowed keys: ports")
end
end
end
context 'when with_image_ports metadata is enabled' do
let(:image_ports) { true }
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
describe '#ports' do
it "returns image's ports" do
expect(entry.ports).to eq ports
end
end
end
end
end
 
context 'when entry value is not correct' do
Loading
Loading
@@ -76,8 +114,8 @@ describe Gitlab::Ci::Config::Entry::Image do
 
describe '#errors' do
it 'saves errors' do
expect(entry.errors)
.to include 'image config should be a hash or a string'
expect(entry.errors.first)
.to match /config should be a hash or a string/
end
end
 
Loading
Loading
@@ -93,8 +131,8 @@ describe Gitlab::Ci::Config::Entry::Image do
 
describe '#errors' do
it 'saves errors' do
expect(entry.errors)
.to include 'image config contains unknown keys: non_existing'
expect(entry.errors.first)
.to match /config contains unknown keys: non_existing/
end
end
 
Loading
Loading
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Port do
let(:entry) { described_class.new(config) }
before do
entry.compose!
end
context 'when configuration is a string' do
let(:config) { 80 }
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
describe '#value' do
it 'returns valid hash' do
expect(entry.value).to eq(number: 80)
end
end
describe '#number' do
it "returns port number" do
expect(entry.number).to eq 80
end
end
describe '#protocol' do
it "is nil" do
expect(entry.protocol).to be_nil
end
end
describe '#name' do
it "is nil" do
expect(entry.name).to be_nil
end
end
end
context 'when configuration is a hash' do
context 'with the complete hash' do
let(:config) do
{ number: 80,
protocol: 'http',
name: 'foobar' }
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
describe '#value' do
it 'returns valid hash' do
expect(entry.value).to eq config
end
end
describe '#number' do
it "returns port number" do
expect(entry.number).to eq 80
end
end
describe '#protocol' do
it "returns port protocol" do
expect(entry.protocol).to eq 'http'
end
end
describe '#name' do
it "returns port name" do
expect(entry.name).to eq 'foobar'
end
end
end
context 'with only the port number' do
let(:config) { { number: 80 } }
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
describe '#value' do
it 'returns valid hash' do
expect(entry.value).to eq(number: 80)
end
end
describe '#number' do
it "returns port number" do
expect(entry.number).to eq 80
end
end
describe '#protocol' do
it "is nil" do
expect(entry.protocol).to be_nil
end
end
describe '#name' do
it "is nil" do
expect(entry.name).to be_nil
end
end
end
context 'without the number' do
let(:config) { { protocol: 'http' } }
describe '#valid?' do
it 'is not valid' do
expect(entry).not_to be_valid
end
end
end
end
context 'when configuration is invalid' do
let(:config) { '80' }
describe '#valid?' do
it 'is valid' do
expect(entry).not_to be_valid
end
end
end
context 'when protocol' do
let(:config) { { number: 80, protocol: protocol, name: 'foobar' } }
context 'is http' do
let(:protocol) { 'http' }
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
context 'is https' do
let(:protocol) { 'https' }
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
context 'is neither http nor https' do
let(:protocol) { 'foo' }
describe '#valid?' do
it 'is invalid' do
expect(entry.errors).to include("port protocol should be http or https")
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Ports do
let(:entry) { described_class.new(config) }
before do
entry.compose!
end
context 'when configuration is valid' do
let(:config) { [{ number: 80, protocol: 'http', name: 'foobar' }] }
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
describe '#value' do
it 'returns valid array' do
expect(entry.value).to eq(config)
end
end
end
context 'when configuration is invalid' do
let(:config) { 'postgresql:9.5' }
describe '#valid?' do
it 'is invalid' do
expect(entry).not_to be_valid
end
end
context 'when any of the ports' do
before do
expect(entry).not_to be_valid
expect(entry.errors.count).to eq 1
end
context 'have the same name' do
let(:config) do
[{ number: 80, protocol: 'http', name: 'foobar' },
{ number: 81, protocol: 'http', name: 'foobar' }]
end
describe '#valid?' do
it 'is invalid' do
expect(entry.errors.first).to match /each port name must be different/
end
end
end
context 'have the same port' do
let(:config) do
[{ number: 80, protocol: 'http', name: 'foobar' },
{ number: 80, protocol: 'http', name: 'foobar1' }]
end
describe '#valid?' do
it 'is invalid' do
expect(entry.errors.first).to match /each port number can only be referenced once/
end
end
end
end
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment