From fe3a37a456dabfe0d4453f3ad284297e7b22151f Mon Sep 17 00:00:00 2001 From: Clemens Hoffmann Date: Mon, 8 Dec 2025 17:32:56 +0100 Subject: [PATCH] Support hash-based routing --- app/actions/manifest_route_update.rb | 11 +- app/actions/route_update.rb | 13 +- app/controllers/v3/routes_controller.rb | 14 +- .../manifest_routes_update_message.rb | 45 +++- app/messages/route_options_message.rb | 69 +++++- app/models/runtime/feature_flag.rb | 3 +- app/models/runtime/route.rb | 19 +- .../route_properties_presenter.rb | 7 +- app/repositories/app_event_repository.rb | 2 + .../includes/resources/routes/_create.md.erb | 29 ++- .../routes/_route_options_object.md.erb | 4 +- .../app_manifest/manifest_route.rb | 2 + .../documentation/feature_flags_api_spec.rb | 2 +- spec/request/routes_spec.rb | 87 +++++++- spec/request/space_manifests_spec.rb | 2 +- spec/unit/actions/route_update_spec.rb | 89 ++++++++ .../manifest_routes_update_message_spec.rb | 72 ++++++- .../messages/route_create_message_spec.rb | 140 ++++++++++++ .../messages/route_options_message_spec.rb | 203 ++++++++++++++++++ .../messages/route_update_message_spec.rb | 102 ++++++++- spec/unit/messages/validators_spec.rb | 48 ++++- .../v3/app_manifest_presenter_spec.rb | 76 +++++++ .../repositories/app_event_repository_spec.rb | 52 +++++ 23 files changed, 1059 insertions(+), 32 deletions(-) create mode 100644 spec/unit/messages/route_options_message_spec.rb diff --git a/app/actions/manifest_route_update.rb b/app/actions/manifest_route_update.rb index e00182e0295..ed1269a2da3 100644 --- a/app/actions/manifest_route_update.rb +++ b/app/actions/manifest_route_update.rb @@ -95,10 +95,15 @@ 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 + # Merge existing route options with manifest options for partial updates + merged_options = if route.options + route.options.deep_symbolize_keys.merge(manifest_route[:options]).compact + else + manifest_route[:options].compact + end + message = RouteUpdateMessage.new({ - 'options' => manifest_route[:options] + 'options' => merged_options }) route = RouteUpdate.new.update(route:, message:) end diff --git a/app/actions/route_update.rb b/app/actions/route_update.rb index 3520ba17877..64e7646b8df 100644 --- a/app/actions/route_update.rb +++ b/app/actions/route_update.rb @@ -2,7 +2,18 @@ module VCAP::CloudController class RouteUpdate def update(route:, message:) Route.db.transaction do - route.options = route.options.symbolize_keys.merge(message.options).compact if message.requested?(:options) + if message.requested?(:options) + merged_options = message.options.compact + + # Clean up invalid option combinations + # If loadbalancing is not 'hash', remove hash-specific options + if merged_options[:loadbalancing] && merged_options[:loadbalancing] != 'hash' + merged_options.delete(:hash_header) + merged_options.delete(:hash_balance) + end + + route.options = merged_options + end route.save MetadataUpdate.update(route, message) end diff --git a/app/controllers/v3/routes_controller.rb b/app/controllers/v3/routes_controller.rb index 86f042dc3a7..2e088613dd8 100644 --- a/app/controllers/v3/routes_controller.rb +++ b/app/controllers/v3/routes_controller.rb @@ -99,7 +99,19 @@ def create end def update - message = RouteUpdateMessage.new(hashed_params[:body]) + params = hashed_params[:body] + + # Merge existing route options with incoming options for partial updates + if params[:options] && route.options + existing_options = route.options.deep_symbolize_keys + params[:options] = existing_options.merge(params[:options].deep_symbolize_keys) + if params[:options][:loadbalancing] && params[:options][:loadbalancing] != 'hash' + params[:options].delete(:hash_header) + params[:options].delete(:hash_balance) + end + end + + message = RouteUpdateMessage.new(params) unprocessable!(message.errors.full_messages) unless message.valid? unauthorized! unless permission_queryer.can_manage_apps_in_active_space?(route.space_id) diff --git a/app/messages/manifest_routes_update_message.rb b/app/messages/manifest_routes_update_message.rb index bc4c6b9cb5e..17cba05a8a2 100644 --- a/app/messages/manifest_routes_update_message.rb +++ b/app/messages/manifest_routes_update_message.rb @@ -69,6 +69,8 @@ def route_options_are_valid def loadbalancings_are_valid return if errors[:routes].present? + valid_algorithms = RouteOptionsMessage.valid_loadbalancing_algorithms + routes.each do |r| next unless r.keys.include?(:options) && r[:options].is_a?(Hash) && r[:options].keys.include?(:loadbalancing) @@ -76,16 +78,53 @@ 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: '#{valid_algorithms.join(', ')}'") next end - RouteOptionsMessage::VALID_LOADBALANCING_ALGORITHMS.exclude?(loadbalancing) && + + if valid_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: '#{valid_algorithms.join(', ')}'") + next + end + + # Validate hash-specific options + if loadbalancing == 'hash' + validate_hash_options_for_route(r) + else + validate_no_hash_options_for_route(r) + end end end + def validate_hash_options_for_route(route) + options = route[:options] + + # hash_header is required for hash algorithm + errors.add(:base, message: "Route '#{route[:route]}': hash_header must be present when loadbalancing is set to hash") if options[:hash_header].blank? + + # hash_balance must be valid if present + return if options[:hash_balance].blank? + + begin + hash_balance = Float(options[:hash_balance]) + errors.add(:base, message: "Route '#{route[:route]}': hash_balance must be greater than or equal to 0.0") if hash_balance < 0.0 + rescue ArgumentError, TypeError + errors.add(:base, message: "Route '#{route[:route]}': hash_balance must be a valid number") + end + end + + def validate_no_hash_options_for_route(route) + options = route[:options] + + errors.add(:base, message: "Route '#{route[:route]}': hash_header can only be set when loadbalancing is hash") if options[:hash_header].present? + + return if options[:hash_balance].blank? + + 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? diff --git a/app/messages/route_options_message.rb b/app/messages/route_options_message.rb index d2fd43e28a6..edb6bfed443 100644 --- a/app/messages/route_options_message.rb +++ b/app/messages/route_options_message.rb @@ -2,15 +2,70 @@ 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 + VALID_MANIFEST_ROUTE_OPTIONS = %i[loadbalancing hash_header hash_balance].freeze + VALID_ROUTE_OPTIONS = %i[loadbalancing hash_header hash_balance].freeze + VALID_LOADBALANCING_ALGORITHMS_WITH_HASH = %w[round-robin least-connection hash].freeze + VALID_LOADBALANCING_ALGORITHMS_WITHOUT_HASH = %w[round-robin least-connection].freeze 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 :validate_loadbalancing_with_feature_flag + + validate :validate_hash_options, if: -> { errors[:loadbalancing].empty? } + + def self.valid_loadbalancing_algorithms + if FeatureFlag.enabled?(:hash_based_routing) + VALID_LOADBALANCING_ALGORITHMS_WITH_HASH + else + VALID_LOADBALANCING_ALGORITHMS_WITHOUT_HASH + end + end + + private + + def validate_loadbalancing_with_feature_flag + return if loadbalancing.nil? + + valid_algorithms = self.class.valid_loadbalancing_algorithms + return if valid_algorithms.include?(loadbalancing) + + errors.add(:loadbalancing, "must be one of '#{valid_algorithms.join(', ')}' if present") + end + + def validate_hash_options + if loadbalancing == 'hash' + validate_hash_header_present + validate_hash_balance_format + else + validate_hash_options_not_present_for_non_hash + end + end + + def validate_hash_header_present + if hash_header.blank? + errors.add(:hash_header, 'must be present when loadbalancing is set to hash') + elsif !hash_header.is_a?(String) + errors.add(:hash_header, 'must be a string') + end + end + + def validate_hash_balance_format + return if hash_balance.nil? + + # Convert string to float if needed (from CLI input) + begin + hash_balance_float = Float(hash_balance) + errors.add(:hash_balance, 'must be greater than or equal to 0.0') if hash_balance_float < 0.0 + rescue ArgumentError, TypeError + errors.add(:hash_balance, 'must be a valid number') + end + end + + def validate_hash_options_not_present_for_non_hash + errors.add(:hash_header, 'can only be set when loadbalancing is hash') if hash_header.present? + return if hash_balance.blank? + + errors.add(:hash_balance, 'can only be set when loadbalancing is hash') + end end end diff --git a/app/models/runtime/feature_flag.rb b/app/models/runtime/feature_flag.rb index 88a5c24a353..df991d89f7e 100644 --- a/app/models/runtime/feature_flag.rb +++ b/app/models/runtime/feature_flag.rb @@ -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[ diff --git a/app/models/runtime/route.rb b/app/models/runtime/route.rb index 70fd484ca42..53ac6661eb9 100644 --- a/app/models/runtime/route.rb +++ b/app/models/runtime/route.rb @@ -71,7 +71,8 @@ def as_summary_json end def options_with_serialization=(opts) - self.options_without_serialization = Oj.dump(opts) + normalized_opts = normalize_hash_balance_to_string(opts) + self.options_without_serialization = Oj.dump(normalized_opts) end alias_method :options_without_serialization=, :options= @@ -216,6 +217,22 @@ def wildcard_host? private + def normalize_hash_balance_to_string(opts) + return opts unless opts.is_a?(Hash) + return opts unless opts.key?('hash_balance') || opts.key?(:hash_balance) + + normalized = opts.dup + hash_balance_key = opts.key?('hash_balance') ? 'hash_balance' : :hash_balance + hash_balance_value = opts[hash_balance_key] + + if hash_balance_value.present? + # Always convert to string for consistent storage in JSON + normalized[hash_balance_key] = hash_balance_value.to_s + end + + normalized + end + def before_destroy destroy_route_bindings super diff --git a/app/presenters/v3/app_manifest_presenters/route_properties_presenter.rb b/app/presenters/v3/app_manifest_presenters/route_properties_presenter.rb index d58c52c3003..824567e1b52 100644 --- a/app/presenters/v3/app_manifest_presenters/route_properties_presenter.rb +++ b/app/presenters/v3/app_manifest_presenters/route_properties_presenter.rb @@ -11,10 +11,13 @@ def to_hash(route_mappings:, app:, **_) } if route_mapping.route.options + opts = route_mapping.route.options + route_hash[:options] = {} - route_hash[:options][:loadbalancing] = route_mapping.route.options[:loadbalancing] if route_mapping.route.options[:loadbalancing] + route_hash[:options][:loadbalancing] = opts['loadbalancing'] if opts.key?('loadbalancing') + route_hash[:options][:hash_header] = opts['hash_header'] if opts.key?('hash_header') + route_hash[:options][:hash_balance] = opts['hash_balance'] if opts.key?('hash_balance') end - route_hash end diff --git a/app/repositories/app_event_repository.rb b/app/repositories/app_event_repository.rb index 84e3e4023e5..4b1c99c14a7 100644 --- a/app/repositories/app_event_repository.rb +++ b/app/repositories/app_event_repository.rb @@ -110,6 +110,7 @@ def record_map_route(user_audit_info, route_mapping, manifest_triggered: false) weight: route_mapping.weight, protocol: route_mapping.protocol }) + metadata[:route_options] = route.options if route.options.present? create_app_audit_event(EventTypes::APP_MAP_ROUTE, app, app.space, actor_hash, metadata) end @@ -126,6 +127,7 @@ def record_unmap_route(user_audit_info, route_mapping, manifest_triggered: false weight: route_mapping.weight, protocol: route_mapping.protocol }) + metadata[:route_options] = route.options if route.options.present? create_app_audit_event(EventTypes::APP_UNMAP_ROUTE, app, app.space, actor_hash, metadata) end diff --git a/docs/v3/source/includes/resources/routes/_create.md.erb b/docs/v3/source/includes/resources/routes/_create.md.erb index 6ad5b93a9f9..702ce526d7c 100644 --- a/docs/v3/source/includes/resources/routes/_create.md.erb +++ b/docs/v3/source/includes/resources/routes/_create.md.erb @@ -23,7 +23,7 @@ curl "https://api.example.org/v3/routes" \ }, "options": { "loadbalancing": "round-robin" - } + }, "metadata": { "labels": { "key": "value" }, "annotations": { "note": "detailed information"} @@ -71,3 +71,30 @@ Admin | Space Developer | Space Supporter | +#### Example with hash-based routing + +```shell +curl "https://api.example.org/v3/routes" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "host": "user-specific-app", + "relationships": { + "domain": { + "data": { "guid": "domain-guid" } + }, + "space": { + "data": { "guid": "space-guid" } + } + }, + "options": { + "loadbalancing": "hash", + "hash_header": "X-User-ID", + "hash_balance": "50.0" + } + }' +``` + +This creates a route that uses hash-based routing on the `X-User-ID` header with a load balance factor of 50.0. + diff --git a/docs/v3/source/includes/resources/routes/_route_options_object.md.erb b/docs/v3/source/includes/resources/routes/_route_options_object.md.erb index 01fc9c82b56..1842fc46288 100644 --- a/docs/v3/source/includes/resources/routes/_route_options_object.md.erb +++ b/docs/v3/source/includes/resources/routes/_route_options_object.md.erb @@ -9,4 +9,6 @@ Example Route-Options object | Name | Type | Description | |-------------------|----------|------------------------------------------------------------------------------------------------------| -| **loadbalancing** | _string_ | The load-balancer associated with this route. Valid values are 'round-robin' and 'least-connection' | +| **loadbalancing** | _string_ | The load-balancer associated with this route. Valid values are 'round-robin', 'least-connection', and 'hash' | +| **hash_header** | _string_ | HTTP header name to hash for routing (e.g., 'X-User-ID', 'Cookie'). Required when loadbalancing is 'hash'. Cannot be set when loadbalancing is not 'hash'. | +| **hash_balance** | _string_ | Weight factor for load balancing (0.0-100.0). 0.0 means ignore server load, higher values consider load more. Optional when loadbalancing is 'hash'. Cannot be set when loadbalancing is not 'hash'. | diff --git a/lib/cloud_controller/app_manifest/manifest_route.rb b/lib/cloud_controller/app_manifest/manifest_route.rb index 113dfe99b9b..3c68ffc9fe0 100644 --- a/lib/cloud_controller/app_manifest/manifest_route.rb +++ b/lib/cloud_controller/app_manifest/manifest_route.rb @@ -18,6 +18,8 @@ def self.parse(route, options=nil) attrs[:full_route] = route attrs[:options] = {} attrs[:options][:loadbalancing] = options[:loadbalancing] if options && options.key?(:loadbalancing) + attrs[:options][:hash_header] = options[:hash_header] if options && options.key?(:hash_header) + attrs[:options][:hash_balance] = options[:hash_balance] if options && options.key?(:hash_balance) ManifestRoute.new(attrs) end diff --git a/spec/api/documentation/feature_flags_api_spec.rb b/spec/api/documentation/feature_flags_api_spec.rb index ce55fbd2c6b..5fe6740eb30 100644 --- a/spec/api/documentation/feature_flags_api_spec.rb +++ b/spec/api/documentation/feature_flags_api_spec.rb @@ -18,7 +18,7 @@ client.get '/v2/config/feature_flags', {}, headers expect(status).to eq(200) - expect(parsed_response.length).to eq(18) + expect(parsed_response.length).to eq(19) expect(parsed_response).to include( { 'name' => 'user_org_creation', diff --git a/spec/request/routes_spec.rb b/spec/request/routes_spec.rb index 2c2fe30cd84..f27ae9cb616 100644 --- a/spec/request/routes_spec.rb +++ b/spec/request/routes_spec.rb @@ -2594,10 +2594,89 @@ end end - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - patch "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) + context 'when updating route options' do + context 'when updating hash-based routing options partially' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + let(:route) do + VCAP::CloudController::Route.make( + space: space, + domain: domain, + host: 'test-hash', + options: { 'loadbalancing' => 'hash', 'hash_header' => 'X-User-ID', 'hash_balance' => 1.5 } + ) + end + + it 'allows updating only hash_balance' do + params = { options: { hash_balance: 2.0 } } + patch "/v3/routes/#{route.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(200) + parsed = parsed_response + expect(parsed['options']['loadbalancing']).to eq('hash') + expect(parsed['options']['hash_header']).to eq('X-User-ID') + expect(parsed['options']['hash_balance']).to eq('2.0') + end + + it 'allows updating only hash_header' do + params = { options: { hash_header: 'X-Session-ID' } } + patch "/v3/routes/#{route.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(200) + parsed = parsed_response + expect(parsed['options']['loadbalancing']).to eq('hash') + expect(parsed['options']['hash_header']).to eq('X-Session-ID') + expect(parsed['options']['hash_balance']).to eq('1.5') + end + + it 'allows updating both hash_header and hash_balance' do + params = { options: { hash_header: 'X-Request-ID', hash_balance: 2.5 } } + patch "/v3/routes/#{route.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(200) + parsed = parsed_response + expect(parsed['options']['loadbalancing']).to eq('hash') + expect(parsed['options']['hash_header']).to eq('X-Request-ID') + expect(parsed['options']['hash_balance']).to eq('2.5') + end + + it 'allows changing to round-robin' do + params = { options: { loadbalancing: 'round-robin' } } + patch "/v3/routes/#{route.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(200) + parsed = parsed_response + expect(parsed['options']['loadbalancing']).to eq('round-robin') + expect(parsed['options']).not_to have_key('hash_header') + expect(parsed['options']).not_to have_key('hash_balance') + end + + it 'does not allow removing hash_header when loadbalancing is hash' do + params = { options: { hash_header: nil } } + patch "/v3/routes/#{route.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message(/Hash header must be present when loadbalancing is set to hash/) + end + end + + context 'when creating hash-based routing from scratch' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + it 'allows setting all hash-based options at once' do + params = { options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: 1.5 } } + patch "/v3/routes/#{route.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(200) + parsed = parsed_response + expect(parsed['options']['loadbalancing']).to eq('hash') + expect(parsed['options']['hash_header']).to eq('X-User-ID') + expect(parsed['options']['hash_balance']).to eq('1.5') + end end end end diff --git a/spec/request/space_manifests_spec.rb b/spec/request/space_manifests_spec.rb index 95990d37b76..4fe7c09b347 100644 --- a/spec/request/space_manifests_spec.rb +++ b/spec/request/space_manifests_spec.rb @@ -647,7 +647,7 @@ expect(last_response).to have_status_code(422) expect(last_response).to have_error_message("For application '#{app1_model.name}': \ -Route 'https://#{route.host}.#{route.domain.name}' contains invalid route option 'doesnt-exist'. Valid keys: 'loadbalancing'") +Route 'https://#{route.host}.#{route.domain.name}' contains invalid route option 'doesnt-exist'. Valid keys: 'loadbalancing, hash_header, hash_balance'") end end diff --git a/spec/unit/actions/route_update_spec.rb b/spec/unit/actions/route_update_spec.rb index 472714d2cad..ea9a401df37 100644 --- a/spec/unit/actions/route_update_spec.rb +++ b/spec/unit/actions/route_update_spec.rb @@ -256,6 +256,95 @@ module VCAP::CloudController subject.update(route:, message:) end end + + context 'when updating hash-based routing options partially' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + route.options = { 'loadbalancing' => 'hash', 'hash_header' => 'X-User-ID', 'hash_balance' => 1.5 } + route.save + end + + context 'when only updating hash_balance' do + let(:body) do + { + options: { + loadbalancing: 'hash', + hash_header: 'X-User-ID', + hash_balance: 2.0 + } + } + end + + it 'updates only hash_balance and keeps other options' do + expect(message).to be_valid + subject.update(route:, message:) + route.reload + expect(route.options).to include({ 'loadbalancing' => 'hash', 'hash_header' => 'X-User-ID', 'hash_balance' => '2.0' }) + end + + it 'notifies the backend' do + expect(fake_route_handler).to receive(:notify_backend_of_route_update) + subject.update(route:, message:) + end + end + + context 'when only updating hash_header' do + let(:body) do + { + options: { + loadbalancing: 'hash', + hash_header: 'X-Session-ID', + hash_balance: 1.5 + } + } + end + + it 'updates only hash_header and keeps other options' do + expect(message).to be_valid + subject.update(route:, message:) + route.reload + expect(route.options).to include({ 'loadbalancing' => 'hash', 'hash_header' => 'X-Session-ID', 'hash_balance' => '1.5' }) + end + end + + context 'when updating both hash_header and hash_balance' do + let(:body) do + { + options: { + loadbalancing: 'hash', + hash_header: 'X-Request-ID', + hash_balance: 2.5 + } + } + end + + it 'updates both values and keeps loadbalancing' do + expect(message).to be_valid + subject.update(route:, message:) + route.reload + expect(route.options).to include({ 'loadbalancing' => 'hash', 'hash_header' => 'X-Request-ID', 'hash_balance' => '2.5' }) + end + end + + context 'when changing loadbalancing from hash to round-robin' do + let(:body) do + { + options: { + loadbalancing: 'round-robin' + } + } + end + + it 'removes hash-specific options' do + expect(message).to be_valid + subject.update(route:, message:) + route.reload + expect(route.options).to eq({ 'loadbalancing' => 'round-robin' }) + expect(route.options).not_to have_key('hash_header') + expect(route.options).not_to have_key('hash_balance') + end + end + end end end end diff --git a/spec/unit/messages/manifest_routes_update_message_spec.rb b/spec/unit/messages/manifest_routes_update_message_spec.rb index f10b4050303..70158a48c3e 100644 --- a/spec/unit/messages/manifest_routes_update_message_spec.rb +++ b/spec/unit/messages/manifest_routes_update_message_spec.rb @@ -157,6 +157,10 @@ module VCAP::CloudController end describe 'route validations' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + context 'when all routes are valid' do let(:body) do { 'routes' => @@ -256,7 +260,9 @@ module VCAP::CloudController expect(msg.valid?).to be(false) expect(msg.errors.errors.length).to eq(1) - expect(msg.errors.full_messages).to include("Route 'existing.example.com' contains invalid route option 'invalid'. Valid keys: 'loadbalancing'") + expect(msg.errors.full_messages).to include( + "Route 'existing.example.com' contains invalid route option 'invalid'. Valid keys: 'loadbalancing, hash_header, hash_balance'" + ) end end @@ -278,6 +284,64 @@ module VCAP::CloudController end end + context 'when a route contains hash algorithm with hash_header' do + let(:body) do + { 'routes' => + [ + { 'route' => 'existing.example.com', + 'options' => { + 'loadbalancing' => 'hash', + 'hash_header' => 'X-User-ID' + } } + ] } + end + + it 'returns true' do + msg = ManifestRoutesUpdateMessage.new(body) + + expect(msg.valid?).to be(true) + end + end + + context 'when a route contains hash algorithm with hash_header and hash_balance' do + let(:body) do + { 'routes' => + [ + { 'route' => 'existing.example.com', + 'options' => { + 'loadbalancing' => 'hash', + 'hash_header' => 'X-User-ID', + 'hash_balance' => 1.5 + } } + ] } + end + + it 'returns true' do + msg = ManifestRoutesUpdateMessage.new(body) + + expect(msg.valid?).to be(true) + end + end + + context 'when a route contains hash algorithm without hash_header' do + let(:body) do + { 'routes' => + [ + { 'route' => 'existing.example.com', + 'options' => { + 'loadbalancing' => 'hash' + } } + ] } + end + + it 'returns false' do + msg = ManifestRoutesUpdateMessage.new(body) + + expect(msg.valid?).to be(false) + expect(msg.errors.full_messages).to include("Route 'existing.example.com': hash_header must be present when loadbalancing is set to hash") + end + end + context 'when a route contains null as a value for loadbalancing' do let(:body) do { 'routes' => @@ -293,7 +357,7 @@ module VCAP::CloudController msg = ManifestRoutesUpdateMessage.new(body) expect(msg.valid?).to be(false) - expect(msg.errors.full_messages).to include("Invalid value for 'loadbalancing' for Route 'existing.example.com'; Valid values are: 'round-robin, least-connection'") + expect(msg.errors.full_messages).to include("Invalid value for 'loadbalancing' for Route 'existing.example.com'; Valid values are: 'round-robin, least-connection, hash'") end end @@ -313,7 +377,9 @@ module VCAP::CloudController expect(msg.valid?).to be(false) expect(msg.errors.errors.length).to eq(1) - expect(msg.errors.full_messages).to include("Cannot use loadbalancing value 'sushi' for Route 'existing.example.com'; Valid values are: 'round-robin, least-connection'") + expect(msg.errors.full_messages).to include( + "Cannot use loadbalancing value 'sushi' for Route 'existing.example.com'; Valid values are: 'round-robin, least-connection, hash'" + ) end end end diff --git a/spec/unit/messages/route_create_message_spec.rb b/spec/unit/messages/route_create_message_spec.rb index cbfe96b8b3d..8af7627d1aa 100644 --- a/spec/unit/messages/route_create_message_spec.rb +++ b/spec/unit/messages/route_create_message_spec.rb @@ -478,6 +478,135 @@ module VCAP::CloudController end end + context 'when loadbalancing has value hash' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + context 'with hash_header' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { data: { guid: 'space-guid' } }, + domain: { data: { guid: 'domain-guid' } } + }, + options: { loadbalancing: 'hash', hash_header: 'X-User-ID' } + } + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'with hash_header and hash_balance as float' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { data: { guid: 'space-guid' } }, + domain: { data: { guid: 'domain-guid' } } + }, + options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: 1.5 } + } + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'with hash_header and hash_balance as string' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { data: { guid: 'space-guid' } }, + domain: { data: { guid: 'domain-guid' } } + }, + options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: '1.5' } + } + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'without hash_header' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { data: { guid: 'space-guid' } }, + domain: { data: { guid: 'domain-guid' } } + }, + options: { loadbalancing: 'hash' } + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:options]).to include('Hash header must be present when loadbalancing is set to hash') + end + end + + context 'with negative hash_balance' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { data: { guid: 'space-guid' } }, + domain: { data: { guid: 'domain-guid' } } + }, + options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: -1.0 } + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:options]).to include('Hash balance must be greater than or equal to 0.0') + end + end + end + + context 'when hash_header is set for non-hash algorithm' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { data: { guid: 'space-guid' } }, + domain: { data: { guid: 'domain-guid' } } + }, + options: { loadbalancing: 'round-robin', hash_header: 'X-User-ID' } + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:options]).to include('Hash header can only be set when loadbalancing is hash') + end + end + + context 'when hash_balance is set for non-hash algorithm' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { data: { guid: 'space-guid' } }, + domain: { data: { guid: 'domain-guid' } } + }, + options: { loadbalancing: 'round-robin', hash_balance: 1.0 } + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:options]).to include('Hash balance can only be set when loadbalancing is hash') + end + end + context 'when loadbalancing has invalid value' do let(:params) do { @@ -494,6 +623,17 @@ module VCAP::CloudController expect(subject).not_to be_valid expect(subject.errors[:options]).to include("Loadbalancing must be one of 'round-robin, least-connection' if present") end + + context 'when hash_based_routing feature flag is enabled' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + it 'is not valid and includes hash in error message' do + expect(subject).not_to be_valid + expect(subject.errors[:options]).to include("Loadbalancing must be one of 'round-robin, least-connection, hash' if present") + end + end end end end diff --git a/spec/unit/messages/route_options_message_spec.rb b/spec/unit/messages/route_options_message_spec.rb new file mode 100644 index 00000000000..4826a14e846 --- /dev/null +++ b/spec/unit/messages/route_options_message_spec.rb @@ -0,0 +1,203 @@ +require 'spec_helper' +require 'messages/route_options_message' + +module VCAP::CloudController + RSpec.describe RouteOptionsMessage do + describe 'validations' do + context 'with hash algorithm' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + context 'when hash_header is provided' do + it 'is valid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: 'X-User-ID' + }) + expect(message).to be_valid + end + + context 'with hash_balance as float' do + it 'is valid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: 'X-User-ID', + hash_balance: 1.5 + }) + expect(message).to be_valid + end + end + + context 'with hash_balance as string' do + it 'is valid and converts to float' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: 'X-User-ID', + hash_balance: '1.5' + }) + expect(message).to be_valid + end + end + + context 'with hash_balance as integer' do + it 'is valid and converts to float' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: 'X-User-ID', + hash_balance: 1 + }) + expect(message).to be_valid + end + end + + context 'with hash_balance as zero' do + it 'is valid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: 'X-User-ID', + hash_balance: 0 + }) + expect(message).to be_valid + end + end + end + + context 'when hash_header is not provided' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash' + }) + expect(message).not_to be_valid + expect(message.errors[:hash_header]).to include('must be present when loadbalancing is set to hash') + end + end + + context 'when hash_header is empty string' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: '' + }) + expect(message).not_to be_valid + expect(message.errors[:hash_header]).to include('must be present when loadbalancing is set to hash') + end + end + + context 'when hash_balance is negative' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: 'X-User-ID', + hash_balance: -1.0 + }) + expect(message).not_to be_valid + expect(message.errors[:hash_balance]).to include('must be greater than or equal to 0.0') + end + end + + context 'when hash_balance is invalid string' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'hash', + hash_header: 'X-User-ID', + hash_balance: 'not-a-number' + }) + expect(message).not_to be_valid + expect(message.errors[:hash_balance]).to include('must be a valid number') + end + end + end + + context 'with round-robin algorithm' do + context 'when hash_header is provided' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'round-robin', + hash_header: 'X-User-ID' + }) + expect(message).not_to be_valid + expect(message.errors[:hash_header]).to include('can only be set when loadbalancing is hash') + end + end + + context 'when hash_balance is provided' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'round-robin', + hash_balance: 1.0 + }) + expect(message).not_to be_valid + expect(message.errors[:hash_balance]).to include('can only be set when loadbalancing is hash') + end + end + + context 'without hash options' do + it 'is valid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'round-robin' + }) + expect(message).to be_valid + end + end + end + + context 'with least-connection algorithm' do + context 'when hash_header is provided' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'least-connection', + hash_header: 'X-User-ID' + }) + expect(message).not_to be_valid + expect(message.errors[:hash_header]).to include('can only be set when loadbalancing is hash') + end + end + + context 'when hash_balance is provided' do + it 'is invalid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'least-connection', + hash_balance: 1.0 + }) + expect(message).not_to be_valid + expect(message.errors[:hash_balance]).to include('can only be set when loadbalancing is hash') + end + end + + context 'without hash options' do + it 'is valid' do + message = RouteOptionsMessage.new({ + loadbalancing: 'least-connection' + }) + expect(message).to be_valid + end + end + end + + context 'with invalid loadbalancing algorithm' do + it 'is invalid without feature flag' do + message = RouteOptionsMessage.new({ + loadbalancing: 'invalid-algorithm' + }) + expect(message).not_to be_valid + expect(message.errors[:loadbalancing]).to include("must be one of 'round-robin, least-connection' if present") + end + + context 'when hash_based_routing feature flag is enabled' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + it 'is invalid and includes hash in error message' do + message = RouteOptionsMessage.new({ + loadbalancing: 'invalid-algorithm' + }) + expect(message).not_to be_valid + expect(message.errors[:loadbalancing]).to include("must be one of 'round-robin, least-connection, hash' if present") + end + end + end + end + end +end diff --git a/spec/unit/messages/route_update_message_spec.rb b/spec/unit/messages/route_update_message_spec.rb index 926e8289ead..1bcc2660d73 100644 --- a/spec/unit/messages/route_update_message_spec.rb +++ b/spec/unit/messages/route_update_message_spec.rb @@ -50,7 +50,58 @@ module VCAP::CloudController expect(message.errors.full_messages[0]).to include("Unknown field(s): 'unexpected'") end - it 'does not accept unknown load-balancing algorithm' do + context 'with hash-based routing feature enabled' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + it 'accepts options params with hash load-balancing algorithm' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash', hash_header: 'X-User-ID' })) + expect(message).to be_valid + end + + it 'accepts options params with hash algorithm and hash_balance' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: 1.5 })) + expect(message).to be_valid + end + + it 'accepts hash_balance as string' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: '1.5' })) + expect(message).to be_valid + end + + it 'does not accept hash algorithm without hash_header' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash' })) + expect(message).not_to be_valid + expect(message.errors.full_messages[0]).to include('Options Hash header must be present when loadbalancing is set to hash') + end + + it 'does not accept hash_header for non-hash algorithm' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'round-robin', hash_header: 'X-User-ID' })) + expect(message).not_to be_valid + expect(message.errors.full_messages[0]).to include('Options Hash header can only be set when loadbalancing is hash') + end + + it 'does not accept hash_balance for non-hash algorithm' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'round-robin', hash_balance: 1.0 })) + expect(message).not_to be_valid + expect(message.errors.full_messages[0]).to include('Options Hash balance can only be set when loadbalancing is hash') + end + + it 'does not accept negative hash_balance' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: -1.0 })) + expect(message).not_to be_valid + expect(message.errors.full_messages[0]).to include('Options Hash balance must be greater than or equal to 0.0') + end + + it 'does not accept unknown load-balancing algorithm' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'cheesecake' })) + expect(message).not_to be_valid + expect(message.errors.full_messages[0]).to include("Options Loadbalancing must be one of 'round-robin, least-connection, hash' if present") + end + end + + it 'does not accept unknown load-balancing algorithm when feature flag is disabled' do message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'cheesecake' })) expect(message).not_to be_valid expect(message.errors.full_messages[0]).to include("Options Loadbalancing must be one of 'round-robin, least-connection' if present") @@ -62,5 +113,54 @@ module VCAP::CloudController expect(message.errors.full_messages[0]).to include("Options Unknown field(s): 'gorgonzola'") end end + + context 'partial updates with pre-merged options' do + let(:params) do + { + metadata: { + labels: { potato: 'yam' }, + annotations: { style: 'mashed' } + } + } + end + + before do + VCAP::CloudController::FeatureFlag.make(name: 'hash_based_routing', enabled: true) + end + + it 'allows updating hash_balance when all required fields are present' do + # Controller would merge: existing {loadbalancing: hash, hash_header: X-User-ID, hash_balance: 1.5} + incoming {hash_balance: 2.0} + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: 2.0 })) + expect(message).to be_valid + end + + it 'allows updating hash_header when all required fields are present' do + # Controller would merge: existing {loadbalancing: hash, hash_header: X-User-ID, hash_balance: 1.5} + incoming {hash_header: X-Session-ID} + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash', hash_header: 'X-Session-ID', hash_balance: 1.5 })) + expect(message).to be_valid + end + + it 'allows updating both hash_header and hash_balance' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash', hash_header: 'X-Request-ID', hash_balance: 2.5 })) + expect(message).to be_valid + end + + it 'does not allow hash_header without hash loadbalancing' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'round-robin', hash_header: 'X-User-ID' })) + expect(message).not_to be_valid + expect(message.errors.full_messages[0]).to include('Options Hash header can only be set when loadbalancing is hash') + end + + it 'allows changing from hash to round-robin (action will clean up hash options)' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'round-robin' })) + expect(message).to be_valid + end + + it 'requires hash_header when loadbalancing is hash' do + message = RouteUpdateMessage.new(params.merge(options: { loadbalancing: 'hash' })) + expect(message).not_to be_valid + expect(message.errors.full_messages[0]).to include('Options Hash header must be present when loadbalancing is set to hash') + end + end end end diff --git a/spec/unit/messages/validators_spec.rb b/spec/unit/messages/validators_spec.rb index a92ac3ced52..7ac8fb0ffdb 100644 --- a/spec/unit/messages/validators_spec.rb +++ b/spec/unit/messages/validators_spec.rb @@ -444,6 +444,12 @@ def lifecycle_type end end + before do + config = double('config', get: 'buildpack') + config_class = double('Config', config:) + stub_const('VCAP::CloudController::Config', config_class) + end + context 'when the lifecycle type provided is invalid' do it 'adds lifecycle_type error message to the base class' do message = lifecycle_class.new({ lifecycle: { type: 'not valid', data: {} } }) @@ -540,6 +546,12 @@ def options_message validates_with OptionsValidator end + before do + feature_flag_class = double('FeatureFlag') + allow(feature_flag_class).to receive(:enabled?).with(:hash_based_routing).and_return(true) + stub_const('VCAP::CloudController::FeatureFlag', feature_flag_class) + end + it 'successfully validates round-robin load-balancing algorithm' do message = OptionsMessage.new({ options: { loadbalancing: 'round-robin' } }) expect(message).to be_valid @@ -550,6 +562,40 @@ def options_message expect(message).to be_valid end + it 'successfully validates hash load-balancing algorithm with hash_header' do + message = OptionsMessage.new({ options: { loadbalancing: 'hash', hash_header: 'X-User-ID' } }) + expect(message).to be_valid + end + + it 'successfully validates hash algorithm with hash_header and hash_balance' do + message = OptionsMessage.new({ options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: 1.5 } }) + expect(message).to be_valid + end + + it 'adds error when hash algorithm is missing hash_header' do + message = OptionsMessage.new({ options: { loadbalancing: 'hash' } }) + expect(message).not_to be_valid + expect(message.errors_on(:options)).to include('Hash header must be present when loadbalancing is set to hash') + end + + it 'adds error when hash_header is set for non-hash algorithm' do + message = OptionsMessage.new({ options: { loadbalancing: 'round-robin', hash_header: 'X-User-ID' } }) + expect(message).not_to be_valid + expect(message.errors_on(:options)).to include('Hash header can only be set when loadbalancing is hash') + end + + it 'adds error when hash_balance is set for non-hash algorithm' do + message = OptionsMessage.new({ options: { loadbalancing: 'round-robin', hash_balance: 1.0 } }) + expect(message).not_to be_valid + expect(message.errors_on(:options)).to include('Hash balance can only be set when loadbalancing is hash') + end + + it 'adds error when hash_balance is negative' do + message = OptionsMessage.new({ options: { loadbalancing: 'hash', hash_header: 'X-User-ID', hash_balance: -1.0 } }) + expect(message).not_to be_valid + expect(message.errors_on(:options)).to include('Hash balance must be greater than or equal to 0.0') + end + it 'successfully validates empty options' do message = OptionsMessage.new({ options: {} }) expect(message).to be_valid @@ -575,7 +621,7 @@ def options_message it 'adds invalid load balancer error message to the base class' do message = OptionsMessage.new({ options: { loadbalancing: 'donuts' } }) expect(message).not_to be_valid - expect(message.errors_on(:options)).to include("Loadbalancing must be one of 'round-robin, least-connection' if present") + expect(message.errors_on(:options)).to include("Loadbalancing must be one of 'round-robin, least-connection, hash' if present") end it 'adds invalid field error message to the base class' do diff --git a/spec/unit/presenters/v3/app_manifest_presenter_spec.rb b/spec/unit/presenters/v3/app_manifest_presenter_spec.rb index 78591220b08..85cd8fdc9f8 100644 --- a/spec/unit/presenters/v3/app_manifest_presenter_spec.rb +++ b/spec/unit/presenters/v3/app_manifest_presenter_spec.rb @@ -292,6 +292,82 @@ module VCAP::CloudController::Presenters::V3 end end + context 'routes with options' do + context 'when routes have hash-based routing with hash_balance' do + let(:route_with_string_balance) do + VCAP::CloudController::Route.make( + host: 'string-balance-route', + options: { 'loadbalancing' => 'hash', 'hash_header' => 'X-Session-ID', 'hash_balance' => '2.0' } + ) + end + let!(:route_mapping_string) { VCAP::CloudController::RouteMappingModel.make(app: app, route: route_with_string_balance) } + end + + context 'when routes have hash-based routing without hash_balance' do + let(:route_without_balance) do + VCAP::CloudController::Route.make( + host: 'no-balance-route', + options: { 'loadbalancing' => 'hash', 'hash_header' => 'X-User-ID' } + ) + end + let!(:route_mapping_no_balance) { VCAP::CloudController::RouteMappingModel.make(app: app, route: route_without_balance) } + + it 'presents the route options without hash_balance' do + result = AppManifestPresenter.new(app, service_bindings, app.route_mappings).to_hash + application = result[:applications].first + + no_balance_route = application[:routes].find { |r| r[:route] == route_without_balance.uri } + expect(no_balance_route).to be_present + expect(no_balance_route[:options]).to eq({ + loadbalancing: 'hash', + hash_header: 'X-User-ID' + }) + end + end + + context 'when routes have round-robin loadbalancing' do + let(:route_round_robin) do + VCAP::CloudController::Route.make( + host: 'round-robin-route', + options: { 'loadbalancing' => 'round-robin' } + ) + end + let!(:route_mapping_rr) { VCAP::CloudController::RouteMappingModel.make(app: app, route: route_round_robin) } + + it 'presents the route with loadbalancing option only' do + result = AppManifestPresenter.new(app, service_bindings, app.route_mappings).to_hash + application = result[:applications].first + + rr_route = application[:routes].find { |r| r[:route] == route_round_robin.uri } + expect(rr_route).to be_present + expect(rr_route[:options]).to eq({ + loadbalancing: 'round-robin' + }) + end + end + + context 'when routes have least-connection loadbalancing' do + let(:route_least_conn) do + VCAP::CloudController::Route.make( + host: 'least-conn-route', + options: { 'loadbalancing' => 'least-connection' } + ) + end + let!(:route_mapping_lc) { VCAP::CloudController::RouteMappingModel.make(app: app, route: route_least_conn) } + + it 'presents the route with loadbalancing option only' do + result = AppManifestPresenter.new(app, service_bindings, app.route_mappings).to_hash + application = result[:applications].first + + lc_route = application[:routes].find { |r| r[:route] == route_least_conn.uri } + expect(lc_route).to be_present + expect(lc_route[:options]).to eq({ + loadbalancing: 'least-connection' + }) + end + end + end + context 'metadata' do context 'when there is no metadata' do before do diff --git a/spec/unit/repositories/app_event_repository_spec.rb b/spec/unit/repositories/app_event_repository_spec.rb index f1d12ea10fc..c2876d70cea 100644 --- a/spec/unit/repositories/app_event_repository_spec.rb +++ b/spec/unit/repositories/app_event_repository_spec.rb @@ -245,6 +245,32 @@ module Repositories expect(event.metadata[:manifest_triggered]).to be_nil end + context 'when route has options' do + let(:route) { Route.make(options: { 'loadbalancing' => 'round-robin' }) } + + it 'includes route_options in the metadata' do + event = app_event_repository.record_map_route(user_audit_info, route_mapping) + expect(event.type).to eq('audit.app.map-route') + expect(event.metadata[:route_guid]).to eq(route.guid) + expect(event.metadata[:route_options]).to eq({ 'loadbalancing' => 'round-robin' }) + end + end + + context 'when route has hash-based routing options' do + let(:route) { Route.make(options: { 'loadbalancing' => 'hash', 'hash_header' => 'X-User-ID', 'hash_balance' => '1.5' }) } + + it 'includes route_options with all hash routing parameters in the metadata' do + event = app_event_repository.record_map_route(user_audit_info, route_mapping) + expect(event.type).to eq('audit.app.map-route') + expect(event.metadata[:route_guid]).to eq(route.guid) + expect(event.metadata[:route_options]).to eq({ + 'loadbalancing' => 'hash', + 'hash_header' => 'X-User-ID', + 'hash_balance' => '1.5' + }) + end + end + context 'when the event is manifest triggered' do let(:manifest_triggered) { true } @@ -348,6 +374,32 @@ module Repositories expect(event.metadata[:manifest_triggered]).to be_nil end + context 'when route has options' do + let(:route) { Route.make(options: { 'loadbalancing' => 'least-connection' }) } + + it 'includes route_options in the metadata' do + event = app_event_repository.record_unmap_route(user_audit_info, route_mapping) + expect(event.type).to eq('audit.app.unmap-route') + expect(event.metadata[:route_guid]).to eq(route.guid) + expect(event.metadata[:route_options]).to eq({ 'loadbalancing' => 'least-connection' }) + end + end + + context 'when route has hash-based routing options' do + let(:route) { Route.make(options: { 'loadbalancing' => 'hash', 'hash_header' => 'X-Session-ID', 'hash_balance' => '2.0' }) } + + it 'includes route_options with all hash routing parameters in the metadata' do + event = app_event_repository.record_unmap_route(user_audit_info, route_mapping) + expect(event.type).to eq('audit.app.unmap-route') + expect(event.metadata[:route_guid]).to eq(route.guid) + expect(event.metadata[:route_options]).to eq({ + 'loadbalancing' => 'hash', + 'hash_header' => 'X-Session-ID', + 'hash_balance' => '2.0' + }) + end + end + context 'when the event is manifest triggered' do it 'includes manifest_triggered in the metadata' do event = app_event_repository.record_unmap_route(user_audit_info, route_mapping, manifest_triggered: true)