Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2c8e854
Add loadbalancing option hash, and hash_header/hash_balance. Add vali…
hoffmaen Dec 16, 2025
9d751e4
Add basic options validation to manifest_routes_update_message
hoffmaen Dec 16, 2025
a8e3b88
Add basic tests for manifest route updates
hoffmaen Dec 16, 2025
1e63c79
Simplify validations, improve error messages, adjust tests
hoffmaen Dec 17, 2025
0371984
Add validation for hash balance being a float
hoffmaen Dec 17, 2025
afd8569
Have consistent validations in manifest_routes_updates_message and ro…
hoffmaen Dec 17, 2025
41fb78a
Minor refactoring
hoffmaen Dec 17, 2025
16e66df
Add check for hash_balance value being 0 or >=1.1
hoffmaen Dec 17, 2025
a7a8862
Transform hash_balance to string in the route model before saving
hoffmaen Dec 17, 2025
42b01f3
Validate the route merged with the route update from the message.
hoffmaen Dec 17, 2025
dfde6ec
Attempt to forward errors in route_update validation to the client
hoffmaen Dec 17, 2025
0710ef1
Add route options changes to app event
hoffmaen Dec 18, 2025
6676cf0
Add new hash options to mnifest route
hoffmaen Dec 18, 2025
bc90e06
Add new hash options to route properties presenter
hoffmaen Dec 18, 2025
3edb661
Move additional validation to route model
hoffmaen Dec 18, 2025
9bf2395
Move final options cleanup to route model
hoffmaen Dec 18, 2025
9d60a3e
Proper error validation and forwarding
hoffmaen Dec 18, 2025
1a4b35b
Forward proper error message on route updates
hoffmaen Dec 18, 2025
0dddc1d
Add some logging to find the manifest issue
hoffmaen Dec 19, 2025
7b0f56c
Add more logging
hoffmaen Dec 19, 2025
981e130
Logging in app_manifest_message
hoffmaen Jan 8, 2026
308173e
Test a few things
hoffmaen Jan 8, 2026
c93cbaa
Test a few things pt 2
hoffmaen Jan 8, 2026
ed35a67
Test a few things pt 3
hoffmaen Jan 8, 2026
e3cffab
Test a few things pt 4
hoffmaen Jan 8, 2026
7379cff
Test a few things pt 5
hoffmaen Jan 8, 2026
9eec93c
Test a few things pt 6
hoffmaen Jan 8, 2026
4eb3b36
Cleanup logging
hoffmaen Jan 8, 2026
69a3ffe
Use symbolized_keys in route_update
hoffmaen Jan 9, 2026
b3f631f
WIP
hoffmaen Jan 9, 2026
cde7879
Add more validations and tests
hoffmaen Jan 9, 2026
3e414e9
Add more tests for route_update
hoffmaen Jan 9, 2026
7373fe2
Add more tests
hoffmaen Jan 9, 2026
335b910
Some test fixes and cleanup
hoffmaen Jan 9, 2026
0c7c747
Use blank? instead of nil? for loadbalancing check
hoffmaen Jan 9, 2026
e293c12
Fix rubocop issues
hoffmaen Jan 12, 2026
9589e16
Fix rubocop spec issues
hoffmaen Jan 12, 2026
d39f0a3
Refactor to resolve rubocop complexity findings
hoffmaen Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions app/actions/manifest_route_update.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def update(app_guid, message, user_audit_info)
)
end
end
rescue Sequel::ValidationFailed, RouteCreate::Error => e
rescue Sequel::ValidationFailed, RouteCreate::Error, RouteUpdate::Error => e
raise InvalidRoute.new(e.message)
end

Expand Down Expand Up @@ -94,9 +94,7 @@ def find_or_create_valid_route(app, manifest_route, user_audit_info)
)
elsif !route.available_in_space?(app.space)
raise InvalidRoute.new('Routes cannot be mapped to destinations in different spaces')
elsif manifest_route[:options] && route[:options] != manifest_route[:options]
# remove nil values from options
manifest_route[:options] = manifest_route[:options].compact
elsif manifest_route[:options]
message = RouteUpdateMessage.new({
'options' => manifest_route[:options]
})
Expand Down
30 changes: 21 additions & 9 deletions app/actions/route_create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,34 @@ def route_resource_manager
end

def validation_error!(error, host, path, port, space, domain)
error!("Invalid domain. Domain '#{domain.name}' is not available in organization '#{space.organization.name}'.") if error.errors.on(:domain)&.include?(:invalid_relation)
check_domain_errors!(error, space, domain)
check_quota_errors!(error, space)
check_route_errors!(error)
validation_error_routing_api!(error)
validation_error_host!(error, host, domain)
validation_error_path!(error, host, path, domain)
validation_error_port!(error, host, port, domain)

error!("Routes quota exceeded for space '#{space.name}'.") if error.errors.on(:space)&.include?(:total_routes_exceeded)
error!(error.message)
end

error!("Reserved route ports quota exceeded for space '#{space.name}'.") if error.errors.on(:space)&.include?(:total_reserved_route_ports_exceeded)
def check_domain_errors!(error, space, domain)
return unless error.errors.on(:domain)&.include?(:invalid_relation)

error!("Routes quota exceeded for organization '#{space.organization.name}'.") if error.errors.on(:organization)&.include?(:total_routes_exceeded)
error!("Invalid domain. Domain '#{domain.name}' is not available in organization '#{space.organization.name}'.")
end

def check_quota_errors!(error, space)
error!("Routes quota exceeded for space '#{space.name}'.") if error.errors.on(:space)&.include?(:total_routes_exceeded)
error!("Reserved route ports quota exceeded for space '#{space.name}'.") if error.errors.on(:space)&.include?(:total_reserved_route_ports_exceeded)
error!("Routes quota exceeded for organization '#{space.organization.name}'.") if error.errors.on(:organization)&.include?(:total_routes_exceeded)
error!("Reserved route ports quota exceeded for organization '#{space.organization.name}'.") if error.errors.on(:organization)&.include?(:total_reserved_route_ports_exceeded)
end

validation_error_routing_api!(error)
validation_error_host!(error, host, domain)
validation_error_path!(error, host, path, domain)
validation_error_port!(error, host, port, domain)
def check_route_errors!(error)
return unless error.errors.on(:route)&.include?(:hash_header_missing)

error!(error.message)
error!('Hash header must be present when loadbalancing is set to hash')
end

def validation_error_routing_api!(error)
Expand Down
17 changes: 16 additions & 1 deletion app/actions/route_update.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
module VCAP::CloudController
class RouteUpdate
class Error < StandardError
end

def update(route:, message:)
Route.db.transaction do
route.options = route.options.symbolize_keys.merge(message.options).compact if message.requested?(:options)
route.options = route.options.symbolize_keys.merge(message.options) if message.requested?(:options)
route.save
MetadataUpdate.update(route, message)
end
Expand All @@ -13,6 +16,18 @@ def update(route:, message:)
end
end
route
rescue Sequel::ValidationFailed => e
validation_error!(e)
end

private

def validation_error!(error)
# Handle hash_header validation error for hash loadbalancing
raise Error.new('Hash header must be present when loadbalancing is set to hash') if error.errors.on(:route)&.include?(:hash_header_missing)

# Fallback for any other validation errors
raise Error.new(error.message)
end
end
end
2 changes: 2 additions & 0 deletions app/controllers/v3/routes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ def update
VCAP::CloudController::RouteUpdate.new.update(route:, message:)

render status: :ok, json: Presenters::V3::RoutePresenter.new(route)
rescue RouteUpdate::Error => e
unprocessable!(e.message)
end

def destroy
Expand Down
79 changes: 74 additions & 5 deletions app/messages/manifest_routes_update_message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def contains_non_route_hash_values?(routes)
validate :route_protocols_are_valid, if: proc { |record| record.requested?(:routes) }
validate :route_options_are_valid, if: proc { |record| record.requested?(:routes) }
validate :loadbalancings_are_valid, if: proc { |record| record.requested?(:routes) }
validate :hash_options_are_valid, if: proc { |record| record.requested?(:routes) }
validate :no_route_is_boolean
validate :default_route_is_boolean
validate :random_route_is_boolean
Expand Down Expand Up @@ -58,10 +59,10 @@ def route_options_are_valid
end

r[:options].each_key do |key|
RouteOptionsMessage::VALID_MANIFEST_ROUTE_OPTIONS.exclude?(key) &&
RouteOptionsMessage.valid_route_options.exclude?(key) &&
errors.add(:base,
message: "Route '#{r[:route]}' contains invalid route option '#{key}'. \
Valid keys: '#{RouteOptionsMessage::VALID_MANIFEST_ROUTE_OPTIONS.join(', ')}'")
Valid keys: '#{RouteOptionsMessage.valid_route_options.join(', ')}'")
end
end
end
Expand All @@ -76,16 +77,84 @@ def loadbalancings_are_valid
unless loadbalancing.is_a?(String)
errors.add(:base,
message: "Invalid value for 'loadbalancing' for Route '#{r[:route]}'; \
Valid values are: '#{RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.join(', ')}'")
Valid values are: '#{RouteOptionsMessage.valid_loadbalancing_algorithms.join(', ')}'")
next
end
RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.exclude?(loadbalancing) &&
RouteOptionsMessage.valid_loadbalancing_algorithms.exclude?(loadbalancing) &&
errors.add(:base,
message: "Cannot use loadbalancing value '#{loadbalancing}' for Route '#{r[:route]}'; \
Valid values are: '#{RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.join(', ')}'")
Valid values are: '#{RouteOptionsMessage.valid_loadbalancing_algorithms.join(', ')}'")
end
end

def hash_options_are_valid
return if errors[:routes].present?

# Only validate hash-specific options when the feature flag is enabled
# If disabled, route_options_are_valid will already report them as invalid
return unless VCAP::CloudController::FeatureFlag.enabled?(:hash_based_routing)

# NOTE: route_options_are_valid already validates that hash_header and hash_balance
# are only allowed when the feature flag is enabled (via valid_route_options).

routes.each do |r|
next unless r.keys.include?(:options) && r[:options].is_a?(Hash)

validate_route_hash_options(r)
end
end

def validate_route_hash_options(route)
options = route[:options]
loadbalancing = options[:loadbalancing]
hash_header = options[:hash_header]
hash_balance = options[:hash_balance]

return if validate_route_hash_header(route, hash_header)
return if validate_route_hash_balance(route, hash_balance)

validate_route_hash_options_with_loadbalancing(route, loadbalancing, hash_header, hash_balance)
end

def validate_route_hash_header(route, hash_header)
return false unless hash_header.present? && (hash_header.to_s.length > 128)

errors.add(:base, message: "Route '#{route[:route]}': Hash header must be at most 128 characters")
true
end

def validate_route_hash_balance(route, hash_balance)
return false if hash_balance.blank?

if hash_balance.to_s.length > 16
errors.add(:base, message: "Route '#{route[:route]}': Hash balance must be at most 16 characters")
return true
end

validate_route_hash_balance_numeric(route, hash_balance)
end

def validate_route_hash_balance_numeric(route, hash_balance)
balance_float = Float(hash_balance)
# Must be either 0 or >= 1.1 and <= 10.0
errors.add(:base, message: "Route '#{route[:route]}': Hash balance must be either 0 or between to 1.1 and 10.0") unless balance_float == 0 || balance_float.between?(1.1, 10)
false
rescue ArgumentError, TypeError
errors.add(:base, message: "Route '#{route[:route]}': Hash balance must be a numeric value")
false
end

def validate_route_hash_options_with_loadbalancing(route, loadbalancing, hash_header, hash_balance)
# When loadbalancing is explicitly set to non-hash value, hash options are not allowed
if hash_header.present? && loadbalancing.present? && loadbalancing != 'hash'
errors.add(:base, message: "Route '#{route[:route]}': Hash header can only be set when loadbalancing is hash")
end

return unless hash_balance.present? && loadbalancing.present? && loadbalancing != 'hash'

errors.add(:base, message: "Route '#{route[:route]}': Hash balance can only be set when loadbalancing is hash")
end

def routes_are_uris
return if errors[:routes].present?

Expand Down
86 changes: 78 additions & 8 deletions app/messages/route_options_message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,85 @@

module VCAP::CloudController
class RouteOptionsMessage < BaseMessage
VALID_MANIFEST_ROUTE_OPTIONS = %i[loadbalancing].freeze
VALID_ROUTE_OPTIONS = %i[loadbalancing].freeze
VALID_LOADBALANCING_ALGORITHMS = %w[round-robin least-connection].freeze
# Register all possible keys upfront so attr_accessors are created
register_allowed_keys %i[loadbalancing hash_header hash_balance]

def self.valid_route_options
options = %i[loadbalancing]
options += %i[hash_header hash_balance] if VCAP::CloudController::FeatureFlag.enabled?(:hash_based_routing)
options.freeze
end

def self.valid_loadbalancing_algorithms
algorithms = %w[round-robin least-connection]
algorithms << 'hash' if VCAP::CloudController::FeatureFlag.enabled?(:hash_based_routing)
algorithms.freeze
end

register_allowed_keys VALID_ROUTE_OPTIONS
validates_with NoAdditionalKeysValidator
validates :loadbalancing,
inclusion: { in: VALID_LOADBALANCING_ALGORITHMS, message: "must be one of '#{RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.join(', ')}' if present" },
presence: true,
allow_nil: true
validate :loadbalancing_algorithm_is_valid
validate :route_options_are_valid
validate :hash_options_are_valid

def loadbalancing_algorithm_is_valid
return if loadbalancing.blank?
return if self.class.valid_loadbalancing_algorithms.include?(loadbalancing)

errors.add(:loadbalancing, "must be one of '#{self.class.valid_loadbalancing_algorithms.join(', ')}' if present")
end

def route_options_are_valid
valid_options = self.class.valid_route_options

# Check if any requested options are not in valid_route_options
# Check needs to be done manually, as the set of allowed options may change during runtime (feature flag)
invalid_keys = requested_keys.select do |key|
value = public_send(key) if respond_to?(key)
value.present? && valid_options.exclude?(key)
end

errors.add(:base, "Unknown field(s): '#{invalid_keys.join("', '")}'") if invalid_keys.any?
end

def hash_options_are_valid
# Only validate hash-specific options when the feature flag is enabled
# If disabled, route_options_are_valid will already report them as unknown fields
return unless VCAP::CloudController::FeatureFlag.enabled?(:hash_based_routing)

validate_hash_header_length
validate_hash_balance_value
validate_hash_options_with_loadbalancing
end

def validate_hash_header_length
return unless hash_header.present? && (hash_header.to_s.length > 128)

errors.add(:hash_header, 'must be at most 128 characters')
end

def validate_hash_balance_value
return if hash_balance.blank?

if hash_balance.to_s.length > 16
errors.add(:hash_balance, 'must be at most 16 characters')
return
end

validate_hash_balance_numeric
end

def validate_hash_balance_numeric
balance_float = Float(hash_balance)
# Must be either 0 or >= 1.1 and <= 10.0
errors.add(:hash_balance, 'must be either 0 or between 1.1 and 10.0') unless balance_float == 0 || balance_float.between?(1.1, 10)
rescue ArgumentError, TypeError
errors.add(:hash_balance, 'must be a numeric value')
end

def validate_hash_options_with_loadbalancing
# When loadbalancing is explicitly set to non-hash value, hash options are not allowed
errors.add(:base, 'Hash header can only be set when loadbalancing is hash') if hash_header.present? && loadbalancing.present? && loadbalancing != 'hash'
errors.add(:base, 'Hash balance can only be set when loadbalancing is hash') if hash_balance.present? && loadbalancing.present? && loadbalancing != 'hash'
end
end
end
3 changes: 2 additions & 1 deletion app/models/runtime/feature_flag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ class UndefinedFeatureFlagError < StandardError
service_instance_sharing: false,
hide_marketplace_from_unauthenticated_users: false,
resource_matching: true,
route_sharing: false
route_sharing: false,
hash_based_routing: false
}.freeze

ADMIN_SKIPPABLE = %i[
Expand Down
Loading