Skip to content

Commit 6ae8cb9

Browse files
committed
Support hash-based routing
1 parent dcfaa2e commit 6ae8cb9

File tree

23 files changed

+1082
-32
lines changed

23 files changed

+1082
-32
lines changed

app/actions/manifest_route_update.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,15 @@ def find_or_create_valid_route(app, manifest_route, user_audit_info)
9595
elsif !route.available_in_space?(app.space)
9696
raise InvalidRoute.new('Routes cannot be mapped to destinations in different spaces')
9797
elsif manifest_route[:options] && route[:options] != manifest_route[:options]
98-
# remove nil values from options
99-
manifest_route[:options] = manifest_route[:options].compact
98+
# Merge existing route options with manifest options for partial updates
99+
merged_options = if route.options
100+
route.options.deep_symbolize_keys.merge(manifest_route[:options]).compact
101+
else
102+
manifest_route[:options].compact
103+
end
104+
100105
message = RouteUpdateMessage.new({
101-
'options' => manifest_route[:options]
106+
'options' => merged_options
102107
})
103108
route = RouteUpdate.new.update(route:, message:)
104109
end

app/actions/route_update.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@ module VCAP::CloudController
22
class RouteUpdate
33
def update(route:, message:)
44
Route.db.transaction do
5-
route.options = route.options.symbolize_keys.merge(message.options).compact if message.requested?(:options)
5+
if message.requested?(:options)
6+
merged_options = message.options.compact
7+
8+
# Clean up invalid option combinations
9+
# If loadbalancing is not 'hash', remove hash-specific options
10+
if merged_options[:loadbalancing] && merged_options[:loadbalancing] != 'hash'
11+
merged_options.delete(:hash_header)
12+
merged_options.delete(:hash_balance)
13+
end
14+
15+
route.options = merged_options
16+
end
617
route.save
718
MetadataUpdate.update(route, message)
819
end

app/controllers/v3/routes_controller.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,19 @@ def create
9999
end
100100

101101
def update
102-
message = RouteUpdateMessage.new(hashed_params[:body])
102+
params = hashed_params[:body]
103+
104+
# Merge existing route options with incoming options for partial updates
105+
if params[:options] && route.options
106+
existing_options = route.options.deep_symbolize_keys
107+
params[:options] = existing_options.merge(params[:options].deep_symbolize_keys)
108+
if params[:options][:loadbalancing] && params[:options][:loadbalancing] != 'hash'
109+
params[:options].delete(:hash_header)
110+
params[:options].delete(:hash_balance)
111+
end
112+
end
113+
114+
message = RouteUpdateMessage.new(params)
103115
unprocessable!(message.errors.full_messages) unless message.valid?
104116

105117
unauthorized! unless permission_queryer.can_manage_apps_in_active_space?(route.space_id)

app/messages/manifest_routes_update_message.rb

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,23 +69,62 @@ def route_options_are_valid
6969
def loadbalancings_are_valid
7070
return if errors[:routes].present?
7171

72+
valid_algorithms = RouteOptionsMessage.valid_loadbalancing_algorithms
73+
7274
routes.each do |r|
7375
next unless r.keys.include?(:options) && r[:options].is_a?(Hash) && r[:options].keys.include?(:loadbalancing)
7476

7577
loadbalancing = r[:options][:loadbalancing]
7678
unless loadbalancing.is_a?(String)
7779
errors.add(:base,
7880
message: "Invalid value for 'loadbalancing' for Route '#{r[:route]}'; \
79-
Valid values are: '#{RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.join(', ')}'")
81+
Valid values are: '#{valid_algorithms.join(', ')}'")
8082
next
8183
end
82-
RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.exclude?(loadbalancing) &&
84+
85+
if valid_algorithms.exclude?(loadbalancing)
8386
errors.add(:base,
8487
message: "Cannot use loadbalancing value '#{loadbalancing}' for Route '#{r[:route]}'; \
85-
Valid values are: '#{RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.join(', ')}'")
88+
Valid values are: '#{valid_algorithms.join(', ')}'")
89+
next
90+
end
91+
92+
# Validate hash-specific options
93+
if loadbalancing == 'hash'
94+
validate_hash_options_for_route(r)
95+
else
96+
validate_no_hash_options_for_route(r)
97+
end
8698
end
8799
end
88100

101+
def validate_hash_options_for_route(route)
102+
options = route[:options]
103+
104+
# hash_header is required for hash algorithm
105+
errors.add(:base, message: "Route '#{route[:route]}': hash_header must be present when loadbalancing is set to hash") if options[:hash_header].blank?
106+
107+
# hash_balance must be valid if present
108+
return if options[:hash_balance].blank?
109+
110+
begin
111+
hash_balance = Float(options[:hash_balance])
112+
errors.add(:base, message: "Route '#{route[:route]}': hash_balance must be greater than or equal to 0.0") if hash_balance < 0.0
113+
rescue ArgumentError, TypeError
114+
errors.add(:base, message: "Route '#{route[:route]}': hash_balance must be a valid number")
115+
end
116+
end
117+
118+
def validate_no_hash_options_for_route(route)
119+
options = route[:options]
120+
121+
errors.add(:base, message: "Route '#{route[:route]}': hash_header can only be set when loadbalancing is hash") if options[:hash_header].present?
122+
123+
return if options[:hash_balance].blank?
124+
125+
errors.add(:base, message: "Route '#{route[:route]}': hash_balance can only be set when loadbalancing is hash")
126+
end
127+
89128
def routes_are_uris
90129
return if errors[:routes].present?
91130

app/messages/route_options_message.rb

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,70 @@
22

33
module VCAP::CloudController
44
class RouteOptionsMessage < BaseMessage
5-
VALID_MANIFEST_ROUTE_OPTIONS = %i[loadbalancing].freeze
6-
VALID_ROUTE_OPTIONS = %i[loadbalancing].freeze
7-
VALID_LOADBALANCING_ALGORITHMS = %w[round-robin least-connection].freeze
5+
VALID_MANIFEST_ROUTE_OPTIONS = %i[loadbalancing hash_header hash_balance].freeze
6+
VALID_ROUTE_OPTIONS = %i[loadbalancing hash_header hash_balance].freeze
7+
VALID_LOADBALANCING_ALGORITHMS_WITH_HASH = %w[round-robin least-connection hash].freeze
8+
VALID_LOADBALANCING_ALGORITHMS_WITHOUT_HASH = %w[round-robin least-connection].freeze
89

910
register_allowed_keys VALID_ROUTE_OPTIONS
1011
validates_with NoAdditionalKeysValidator
11-
validates :loadbalancing,
12-
inclusion: { in: VALID_LOADBALANCING_ALGORITHMS, message: "must be one of '#{RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.join(', ')}' if present" },
13-
presence: true,
14-
allow_nil: true
12+
validate :validate_loadbalancing_with_feature_flag
13+
14+
validate :validate_hash_options, if: -> { errors[:loadbalancing].empty? }
15+
16+
def self.valid_loadbalancing_algorithms
17+
if FeatureFlag.enabled?(:hash_based_routing)
18+
VALID_LOADBALANCING_ALGORITHMS_WITH_HASH
19+
else
20+
VALID_LOADBALANCING_ALGORITHMS_WITHOUT_HASH
21+
end
22+
end
23+
24+
private
25+
26+
def validate_loadbalancing_with_feature_flag
27+
return if loadbalancing.nil?
28+
29+
valid_algorithms = self.class.valid_loadbalancing_algorithms
30+
return if valid_algorithms.include?(loadbalancing)
31+
32+
errors.add(:loadbalancing, "must be one of '#{valid_algorithms.join(', ')}' if present")
33+
end
34+
35+
def validate_hash_options
36+
if loadbalancing == 'hash'
37+
validate_hash_header_present
38+
validate_hash_balance_format
39+
else
40+
validate_hash_options_not_present_for_non_hash
41+
end
42+
end
43+
44+
def validate_hash_header_present
45+
if hash_header.blank?
46+
errors.add(:hash_header, 'must be present when loadbalancing is set to hash')
47+
elsif !hash_header.is_a?(String)
48+
errors.add(:hash_header, 'must be a string')
49+
end
50+
end
51+
52+
def validate_hash_balance_format
53+
return if hash_balance.nil?
54+
55+
# Convert string to float if needed (from CLI input)
56+
begin
57+
hash_balance_float = Float(hash_balance)
58+
errors.add(:hash_balance, 'must be greater than or equal to 0.0') if hash_balance_float < 0.0
59+
rescue ArgumentError, TypeError
60+
errors.add(:hash_balance, 'must be a valid number')
61+
end
62+
end
63+
64+
def validate_hash_options_not_present_for_non_hash
65+
errors.add(:hash_header, 'can only be set when loadbalancing is hash') if hash_header.present?
66+
return if hash_balance.blank?
67+
68+
errors.add(:hash_balance, 'can only be set when loadbalancing is hash')
69+
end
1570
end
1671
end

app/models/runtime/feature_flag.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ class UndefinedFeatureFlagError < StandardError
2323
service_instance_sharing: false,
2424
hide_marketplace_from_unauthenticated_users: false,
2525
resource_matching: true,
26-
route_sharing: false
26+
route_sharing: false,
27+
hash_based_routing: false
2728
}.freeze
2829

2930
ADMIN_SKIPPABLE = %i[

app/models/runtime/route.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ def as_summary_json
7171
end
7272

7373
def options_with_serialization=(opts)
74-
self.options_without_serialization = Oj.dump(opts)
74+
normalized_opts = normalize_hash_balance_to_string(opts)
75+
self.options_without_serialization = Oj.dump(normalized_opts)
7576
end
7677

7778
alias_method :options_without_serialization=, :options=
@@ -216,6 +217,22 @@ def wildcard_host?
216217

217218
private
218219

220+
def normalize_hash_balance_to_string(opts)
221+
return opts unless opts.is_a?(Hash)
222+
return opts unless opts.key?('hash_balance') || opts.key?(:hash_balance)
223+
224+
normalized = opts.dup
225+
hash_balance_key = opts.key?('hash_balance') ? 'hash_balance' : :hash_balance
226+
hash_balance_value = opts[hash_balance_key]
227+
228+
if hash_balance_value.present?
229+
# Always convert to string for consistent storage in JSON
230+
normalized[hash_balance_key] = hash_balance_value.to_s
231+
end
232+
233+
normalized
234+
end
235+
219236
def before_destroy
220237
destroy_route_bindings
221238
super

app/presenters/v3/app_manifest_presenters/route_properties_presenter.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ def to_hash(route_mappings:, app:, **_)
1111
}
1212

1313
if route_mapping.route.options
14+
opts = route_mapping.route.options
15+
1416
route_hash[:options] = {}
15-
route_hash[:options][:loadbalancing] = route_mapping.route.options[:loadbalancing] if route_mapping.route.options[:loadbalancing]
17+
route_hash[:options][:loadbalancing] = opts['loadbalancing'] if opts.key?('loadbalancing')
18+
route_hash[:options][:hash_header] = opts['hash_header'] if opts.key?('hash_header')
19+
route_hash[:options][:hash_balance] = opts['hash_balance'] if opts.key?('hash_balance')
1620
end
17-
1821
route_hash
1922
end
2023

app/repositories/app_event_repository.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def record_map_route(user_audit_info, route_mapping, manifest_triggered: false)
110110
weight: route_mapping.weight,
111111
protocol: route_mapping.protocol
112112
})
113+
metadata[:route_options] = route.options if route.options.present?
113114
create_app_audit_event(EventTypes::APP_MAP_ROUTE, app, app.space, actor_hash, metadata)
114115
end
115116

@@ -126,6 +127,7 @@ def record_unmap_route(user_audit_info, route_mapping, manifest_triggered: false
126127
weight: route_mapping.weight,
127128
protocol: route_mapping.protocol
128129
})
130+
metadata[:route_options] = route.options if route.options.present?
129131
create_app_audit_event(EventTypes::APP_UNMAP_ROUTE, app, app.space, actor_hash, metadata)
130132
end
131133

docs/v3/source/includes/resources/routes/_create.md.erb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ curl "https://api.example.org/v3/routes" \
2323
},
2424
"options": {
2525
"loadbalancing": "round-robin"
26-
}
26+
},
2727
"metadata": {
2828
"labels": { "key": "value" },
2929
"annotations": { "note": "detailed information"}
@@ -71,3 +71,30 @@ Admin |
7171
Space Developer |
7272
Space Supporter |
7373

74+
#### Example with hash-based routing
75+
76+
```shell
77+
curl "https://api.example.org/v3/routes" \
78+
-X POST \
79+
-H "Authorization: bearer [token]" \
80+
-H "Content-type: application/json" \
81+
-d '{
82+
"host": "user-specific-app",
83+
"relationships": {
84+
"domain": {
85+
"data": { "guid": "domain-guid" }
86+
},
87+
"space": {
88+
"data": { "guid": "space-guid" }
89+
}
90+
},
91+
"options": {
92+
"loadbalancing": "hash",
93+
"hash_header": "X-User-ID",
94+
"hash_balance": "50.0"
95+
}
96+
}'
97+
```
98+
99+
This creates a route that uses hash-based routing on the `X-User-ID` header with a load balance factor of 50.0.
100+

0 commit comments

Comments
 (0)