Skip to content

Commit

Permalink
Introduce per-route options
Browse files Browse the repository at this point in the history
This commit adds a configurable load-balancing algorithm as a first example of per-route options.
It adds the 'options' field to the route object in the V3 API, and the app manifest.
The options field is an object storing key-value pairs, with 'lb_algo' being the only supported key for now.
The supported load-balancing algorithms are 'round-robin' and 'least-connections'.
The options field is introduced as a column in the database route table, and forwarded to the Diego backend.

Co-authored-by: Alexander Nicke <[email protected]>
See: cloudfoundry/capi-release#482
See: https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0027-generic-per-route-features.md
  • Loading branch information
a18e authored and hoffmaen committed Dec 5, 2024
1 parent 7536b72 commit 92dff5c
Show file tree
Hide file tree
Showing 25 changed files with 606 additions and 23 deletions.
10 changes: 9 additions & 1 deletion app/actions/manifest_route_update.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'cloud_controller/app_manifest/manifest_route'
require 'actions/route_create'
require 'actions/route_update'

module VCAP::CloudController
class ManifestRouteUpdate
Expand Down Expand Up @@ -81,7 +82,8 @@ def find_or_create_valid_route(app, manifest_route, user_audit_info)
message = RouteCreateMessage.new({
'host' => host,
'path' => manifest_route[:path],
'port' => manifest_route[:port]
'port' => manifest_route[:port],
'options' => manifest_route[:options]
})

route = RouteCreate.new(user_audit_info).create(
Expand All @@ -90,6 +92,12 @@ def find_or_create_valid_route(app, manifest_route, user_audit_info)
domain: existing_domain,
manifest_triggered: true
)
elsif route[:options] != manifest_route[:options]
message = RouteUpdateMessage.new({
'options' => manifest_route[:options]
})
route = RouteUpdate.new.update(route:, message:)

elsif route.space.guid != app.space_guid
raise InvalidRoute.new('Routes cannot be mapped to destinations in different spaces')
end
Expand Down
3 changes: 2 additions & 1 deletion app/actions/route_create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ def create(message:, space:, domain:, manifest_triggered: false)
path: message.path || '',
port: port(message, domain),
space: space,
domain: domain
domain: domain,
options: message.options
)

Route.db.transaction do
Expand Down
10 changes: 10 additions & 0 deletions app/actions/route_update.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ module VCAP::CloudController
class RouteUpdate
def update(route:, message:)
Route.db.transaction do
if message.requested?(:options)
route.options = if message.options.nil?
nil
elsif route.options.nil?
message.options
else
route.options.merge(message.options)
end
end
route.save
MetadataUpdate.update(route, message)
end

Expand Down
42 changes: 38 additions & 4 deletions app/messages/manifest_routes_update_message.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'messages/base_message'
require 'messages/route_options_message'
require 'cloud_controller/app_manifest/manifest_route'

module VCAP::CloudController
Expand All @@ -7,9 +8,20 @@ class ManifestRoutesUpdateMessage < BaseMessage

class ManifestRoutesYAMLValidator < ActiveModel::Validator
def validate(record)
return unless is_not_array?(record.routes) || contains_non_route_hash_values?(record.routes)
if is_not_array?(record.routes) || contains_non_route_hash_values?(record.routes)
record.errors.add(:routes, message: 'must be a list of route objects')
return
end

record.errors.add(:routes, message: 'must be a list of route objects')
if contains_invalid_route_options?(record.routes)
record.errors.add(:routes, message: 'contains invalid route options')
return
end

return unless contains_invalid_lb_algo?(record.routes)

record.errors.add(:routes, message: 'contains an invalid loadbalancing-algorithm option')
nil
end

def is_not_array?(routes)
Expand All @@ -19,6 +31,26 @@ def is_not_array?(routes)
def contains_non_route_hash_values?(routes)
routes.any? { |r| !(r.is_a?(Hash) && r[:route].present?) }
end

def contains_invalid_route_options?(routes)
routes.any? do |r|
next unless r[:options]

return true unless r[:options].is_a?(Hash)

return false if r[:options].empty?

return r[:options].keys.all? { |key| RouteOptionsMessage::VALID_MANIFEST_ROUTE_OPTIONS.exclude?(key) }
end
end

def contains_invalid_lb_algo?(routes)
routes.any? do |r|
next unless r[:options] && r[:options][:'loadbalancing-algorithm']

return true if r[:options][:'loadbalancing-algorithm'] && RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.exclude?(r[:options][:'loadbalancing-algorithm'])
end
end
end

validates_with NoAdditionalKeysValidator
Expand All @@ -32,10 +64,12 @@ def contains_non_route_hash_values?(routes)

def manifest_route_mappings
@manifest_route_mappings ||= routes.map do |route|
{
route: ManifestRoute.parse(route[:route]),
r = {
route: ManifestRoute.parse(route[:route], route[:options]),
protocol: route[:protocol]
}
r[:options] = route[:options] unless route[:options].nil?
r
end
end

Expand Down
7 changes: 7 additions & 0 deletions app/messages/route_create_message.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'messages/metadata_base_message'
require 'messages/route_options_message'

module VCAP::CloudController
class RouteCreateMessage < MetadataBaseMessage
Expand All @@ -10,6 +11,7 @@ class RouteCreateMessage < MetadataBaseMessage
path
port
relationships
options
]

validates :host,
Expand Down Expand Up @@ -56,6 +58,7 @@ class RouteCreateMessage < MetadataBaseMessage

validates_with NoAdditionalKeysValidator
validates_with RelationshipValidator
validates_with OptionsValidator

delegate :space_guid, to: :relationships_message
delegate :domain_guid, to: :relationships_message
Expand All @@ -65,6 +68,10 @@ def relationships_message
@relationships_message ||= Relationships.new(relationships&.deep_symbolize_keys)
end

def options_message
@options_message ||= RouteOptionsMessage.new(options&.deep_symbolize_keys)
end

def wildcard?
host == '*'
end
Expand Down
16 changes: 16 additions & 0 deletions app/messages/route_options_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
require 'messages/metadata_base_message'

module VCAP::CloudController
class RouteOptionsMessage < BaseMessage
VALID_MANIFEST_ROUTE_OPTIONS = %i[loadbalancing-algorithm].freeze
VALID_ROUTE_OPTIONS = %i[lb_algo].freeze
VALID_LOADBALANCING_ALGORITHMS = %w[round-robin least-connections].freeze

register_allowed_keys %i[lb_algo]
validates_with NoAdditionalKeysValidator
validates :lb_algo,
inclusion: { in: VALID_LOADBALANCING_ALGORITHMS, message: "'%<value>s' is not a supported load-balancing algorithm" },
presence: true,
allow_nil: true
end
end
13 changes: 12 additions & 1 deletion app/messages/route_update_message.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
require 'messages/metadata_base_message'
require 'messages/route_options_message'

module VCAP::CloudController
class RouteUpdateMessage < MetadataBaseMessage
register_allowed_keys []
register_allowed_keys %i[options]

def self.options_requested?
@options_requested ||= proc { |a| a.requested?(:options) }
end

def options_message
@options_message ||= RouteOptionsMessage.new(options&.deep_symbolize_keys)
end

validates_with OptionsValidator, if: options_requested?

validates_with NoAdditionalKeysValidator
end
Expand Down
20 changes: 20 additions & 0 deletions app/messages/validators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,26 @@ def validate(record)
end
end

class OptionsValidator < ActiveModel::Validator
def validate(record)
# Empty option hashes are allowed, so we skip further validation
record.options.blank? && return

unless record.options.is_a?(Hash)
record.errors.add(:options, message: "'options' is not a valid object")
return
end

opt = record.options_message

return if opt.valid?

opt.errors.full_messages.each do |message|
record.errors.add(:options, message:)
end
end
end

class ToOneRelationshipValidator < ActiveModel::EachValidator
def validate_each(record, attribute, relationship)
if has_correct_structure?(relationship)
Expand Down
21 changes: 19 additions & 2 deletions app/models/runtime/route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ class OutOfVIPException < CloudController::Errors::InvalidRelation; end

add_association_dependencies route_mappings: :destroy

export_attributes :host, :path, :domain_guid, :space_guid, :service_instance_guid, :port
import_attributes :host, :path, :domain_guid, :space_guid, :app_guids, :port
export_attributes :host, :path, :domain_guid, :space_guid, :service_instance_guid, :port, :options
import_attributes :host, :path, :domain_guid, :space_guid, :app_guids, :port, :options

add_association_dependencies labels: :destroy
add_association_dependencies annotations: :destroy
Expand Down Expand Up @@ -71,6 +71,23 @@ def as_summary_json
}
end

def options_with_serialization=(opts)
self.options_without_serialization = Oj.dump(opts)
end

alias_method :options_without_serialization=, :options=
alias_method :options=, :options_with_serialization=

def options_with_serialization
string = options_without_serialization
return nil if string.blank?

Oj.load(string)
end

alias_method :options_without_serialization, :options
alias_method :options, :options_with_serialization

alias_method :old_path, :path
def path
old_path.nil? ? '' : old_path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ module AppManifestPresenters
class RoutePropertiesPresenter
def to_hash(route_mappings:, app:, **_)
route_hashes = route_mappings.map do |route_mapping|
{
route_hash = {
route: route_mapping.route.uri,
protocol: route_mapping.protocol
}

if route_mapping.route.options
route_hash[:options] = {}
route_hash[:options][:'loadbalancing-algorithm'] = route_mapping.route.options[:lb_algo] if route_mapping.route.options[:lb_algo]
end

route_hash
end

{ routes: alphabetize(route_hashes).presence }
Expand Down
1 change: 1 addition & 0 deletions app/presenters/v3/route_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def to_hash
},
links: build_links
}
hash.merge!(options: route.options) unless route.options.nil?

@decorators.reduce(hash) { |memo, d| d.decorate(memo, [route]) }
end
Expand Down
14 changes: 14 additions & 0 deletions db/migrations/20241105000000_add_options_to_route.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Sequel.migration do
up do
alter_table(:routes) do
# rubocop:disable Migration/IncludeStringSize
add_column :options, String, text: true, default: nil
# rubocop:enable Migration/IncludeStringSize
end
end
down do
alter_table(:routes) do
drop_column :options
end
end
end
20 changes: 16 additions & 4 deletions lib/cloud_controller/app_manifest/manifest_route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,31 @@ class ManifestRoute
SUPPORTED_TCP_SCHEMES = %w[tcp unspecified].freeze
WILDCARD_HOST = '*'.freeze

def self.parse(string)
parsed_uri = Addressable::URI.heuristic_parse(string, scheme: 'unspecified')
def self.parse(route, options=nil)
parsed_uri = Addressable::URI.heuristic_parse(route, scheme: 'unspecified')

attrs = if parsed_uri.nil?
{}
else
parsed_uri.to_hash
end

attrs[:full_route] = string
attrs[:full_route] = route

if options
attrs[:options] = {}
attrs[:options][:lb_algo] = options[:'loadbalancing-algorithm'] if options[:'loadbalancing-algorithm']
end

ManifestRoute.new(attrs)
end

def valid?
if @attrs[:options] && !@attrs[:options].empty?
return false if @attrs[:options].keys.any? { |key| RouteOptionsMessage::VALID_ROUTE_OPTIONS.exclude?(key) }
return false if @attrs[:options][:lb_algo] && RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.exclude?(@attrs[:options][:lb_algo])
end

return false if @attrs[:host].blank?

return SUPPORTED_TCP_SCHEMES.include?(@attrs[:scheme]) if @attrs[:port]
Expand All @@ -43,7 +54,8 @@ def to_hash
{
candidate_host_domain_pairs: pairs,
port: @attrs[:port],
path: @attrs[:path]
path: @attrs[:path],
options: @attrs[:options]
}
end

Expand Down
4 changes: 3 additions & 1 deletion lib/cloud_controller/diego/app_recipe_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,15 @@ def health_check_timeout_in_seconds

def generate_routes(info)
http_routes = (info['http_routes'] || []).map do |i|
{
http_route = {
hostnames: [i['hostname']],
port: i['port'],
route_service_url: i['route_service_url'],
isolation_segment: IsolationSegmentSelector.for_space(process.space),
protocol: i['protocol']
}
http_route[:options] = i['options'] if i['options']
http_route
end

{
Expand Down
1 change: 1 addition & 0 deletions lib/cloud_controller/diego/protocol/routing_info.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def http_info(process_eager)
info['router_group_guid'] = r.domain.router_group_guid if r.domain.is_a?(SharedDomain) && !r.domain.router_group_guid.nil?
info['port'] = get_port_to_use(route_mapping)
info['protocol'] = route_mapping.protocol
info['options'] = r.options if r.options
info
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/cloud_controller/route_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class ValidationError < StandardError; end
class DomainInvalid < ValidationError; end
class RouteInvalid < ValidationError; end
class RoutePortTaken < ValidationError; end
class InvalidRouteOptions < ValidationError; end

attr_reader :route

Expand Down
Loading

0 comments on commit 92dff5c

Please sign in to comment.