Skip to content

Commit 89a719e

Browse files
authored
Move service binding uniquness validation into ccng (#4521)
* Move service binding uniqueness validation into ccng Allows operators to set the number of allowed service bindings via parameter `max_service_credential_bindings_per_app_service_instance`. (currently hard coded to 1) This will be set to 1 in capi-release to not change the current behavior. If set to >1 developers can create multiple bindings for the same app and service instance. This is useful e.g. for rotating the binding credentials without pushing the app again. Restart/restage is sufficient to put the latest binding into the app. To ensure users cannot run create multiple service bindings during the bosh deployment (when old and new code is running) we need to first introduce the new validation logic so that we can safely remove the unique constraints in the next capi release. Further details can be found in RFC-0040: https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0040-service-binding-rotation.md * Allow only one binding for v2 API * fix test issues * Remove db migration and config parameter e. * skip tests which require unique constraint removal * remove config parameter * Lock service instance to prevent concurrent binding creation
1 parent 50455b9 commit 89a719e

File tree

13 files changed

+302
-95
lines changed

13 files changed

+302
-95
lines changed

app/actions/service_credential_binding_app_create.rb

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,47 @@ def initialize(user_audit_info, audit_hash, manifest_triggered: false)
1919

2020
def precursor(service_instance, message:, app: nil, volume_mount_services_enabled: false)
2121
validate_service_instance!(app, service_instance, volume_mount_services_enabled)
22-
binding = ServiceBinding.first(service_instance:, app:)
23-
validate_binding!(binding)
2422

25-
binding_details = {
23+
new_binding_details = {
2624
service_instance: service_instance,
2725
name: message.name,
2826
app: app,
2927
type: 'app',
3028
credentials: {}
3129
}
3230

33-
ServiceBinding.new.tap do |b|
31+
ServiceBinding.new.tap do |new_binding|
3432
ServiceBinding.db.transaction do
35-
if binding
36-
binding.destroy
37-
VCAP::Services::ServiceBrokers::V2::OrphanMitigator.new.cleanup_failed_bind(binding)
33+
# Lock the service instance to prevent multiple bindings being created when not allowed
34+
service_instance.lock! if max_bindings_per_app_service_instance == 1
35+
36+
validate_app_guid_name_uniqueness!(app.guid, message.name, service_instance.guid)
37+
38+
num_valid_bindings = 0
39+
existing_bindings = ServiceBinding.where(service_instance:, app:)
40+
existing_bindings.each do |binding|
41+
binding.lock!
42+
43+
if binding.create_failed?
44+
binding.destroy
45+
VCAP::Services::ServiceBrokers::V2::OrphanMitigator.new.cleanup_failed_bind(binding)
46+
next
47+
end
48+
49+
validate_binding!(binding, desired_binding_name: message.name)
50+
num_valid_bindings += 1
3851
end
39-
b.save_with_attributes_and_new_operation(
40-
binding_details,
52+
53+
validate_number_of_bindings!(num_valid_bindings)
54+
55+
new_binding.save_with_attributes_and_new_operation(
56+
new_binding_details,
4157
CREATE_INITIAL_OPERATION
4258
)
43-
MetadataUpdate.update(b, message)
59+
MetadataUpdate.update(new_binding, message)
4460
end
4561
end
4662
rescue Sequel::ValidationFailed => e
47-
if e.message.include?('app_guid and name unique')
48-
raise UnprocessableCreate.new("The binding name is invalid. App binding names must be unique. The app already has a binding with name '#{message.name}'.")
49-
elsif e.message.include?('service_instance_guid and app_guid unique')
50-
raise UnprocessableCreate.new('The app is already bound to the service instance.')
51-
end
52-
5363
raise UnprocessableCreate.new(e.full_message)
5464
end
5565

@@ -66,11 +76,25 @@ def validate_service_instance!(app, service_instance, volume_mount_services_enab
6676
operation_in_progress! if service_instance.operation_in_progress?
6777
end
6878

69-
def validate_binding!(binding)
70-
return unless binding
79+
def validate_binding!(binding, desired_binding_name:)
80+
already_bound! if (max_bindings_per_app_service_instance == 1) && (binding.create_succeeded? || binding.create_in_progress?)
81+
binding_in_progress!(binding.guid) if binding.create_in_progress?
82+
incomplete_deletion! if binding.delete_in_progress? || binding.delete_failed?
83+
84+
name_cannot_be_changed! if binding.name != desired_binding_name
85+
end
86+
87+
def validate_number_of_bindings!(number_of_bindings)
88+
too_many_bindings! if number_of_bindings >= max_bindings_per_app_service_instance
89+
end
90+
91+
def validate_app_guid_name_uniqueness!(target_app_guid, desired_binding_name, target_service_instance_guid)
92+
return if desired_binding_name.nil?
7193

72-
already_bound! if binding.create_succeeded? || binding.create_in_progress?
73-
incomplete_deletion! if binding.delete_failed? || binding.delete_in_progress?
94+
dataset = ServiceBinding.where(app_guid: target_app_guid, name: desired_binding_name)
95+
96+
name_uniqueness_violation!(desired_binding_name) if max_bindings_per_app_service_instance == 1 && dataset.first
97+
name_uniqueness_violation!(desired_binding_name) if dataset.exclude(service_instance_guid: target_service_instance_guid).first
7498
end
7599

76100
def permitted_binding_attributes
@@ -87,6 +111,15 @@ def event_repository
87111
)
88112
end
89113

114+
def max_bindings_per_app_service_instance
115+
1
116+
# NOTE: This is hard-coded to 1 for now to preserve the old uniqueness behavior.
117+
# TODO: Once the DB migration that drops the unique constraints for service bindings has been released,
118+
# this should be switched to read from config:
119+
# VCAP::CloudController::Config.config.get(:max_service_credential_bindings_per_app_service_instance)
120+
# TODO: Also remove skips in related specs.
121+
end
122+
90123
def app_is_required!
91124
raise UnprocessableCreate.new('No app was specified')
92125
end
@@ -95,6 +128,27 @@ def not_supported!
95128
raise Unimplemented.new('Cannot create credential bindings for managed service instances')
96129
end
97130

131+
def binding_in_progress!(binding_guid)
132+
raise UnprocessableCreate.new("There is already a binding in progress for this service instance and app (binding guid: #{binding_guid})")
133+
end
134+
135+
def too_many_bindings!
136+
raise UnprocessableCreate.new(
137+
"The app has too many bindings to this service instance (limit: #{max_bindings_per_app_service_instance}). Consider deleting existing/orphaned bindings."
138+
)
139+
end
140+
141+
def name_cannot_be_changed!
142+
raise UnprocessableCreate.new('The binding name cannot be changed for the same app and service instance')
143+
end
144+
145+
def name_uniqueness_violation!(name)
146+
msg = 'The binding name is invalid. Binding names must be unique for a given service instance and app.'
147+
msg += " The app already has a binding with name '#{name}'." unless name.nil? || name.empty?
148+
149+
raise UnprocessableCreate.new(msg)
150+
end
151+
98152
def already_bound!
99153
raise UnprocessableCreate.new('The app is already bound to the service instance')
100154
end

app/actions/v2/services/service_binding_create.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def create(app, service_instance, message, volume_mount_services_enabled, accept
2929
raise SpaceMismatch unless bindable_in_space?(service_instance, app.space)
3030

3131
raise_if_instance_locked(service_instance)
32+
raise_if_binding_already_exists(service_instance, app)
3233

3334
binding = ServiceBinding.new(
3435
service_instance: service_instance,
@@ -82,6 +83,12 @@ def bindable_in_space?(service_instance, app_space)
8283
service_instance.space == app_space || service_instance.shared_spaces.include?(app_space)
8384
end
8485

86+
def raise_if_binding_already_exists(service_instance, app)
87+
return unless ServiceBinding.where(service_instance:, app:).any?
88+
89+
raise CloudController::Errors::ApiError.new_from_details('ServiceBindingAppServiceTaken', 'The app is already bound to the service.')
90+
end
91+
8592
def logger
8693
@logger ||= Steno.logger('cc.action.service_binding_create')
8794
end

app/controllers/services/service_instances_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ def initialize(service_instance)
415415
end
416416

417417
def serialize(_controller, space, _opts, _orphans=nil)
418-
bound_app_count = ServiceBindingListFetcher.fetch_service_instance_bindings_in_space(@service_instance.guid, space.guid).count
418+
bound_app_count = ServiceBindingListFetcher.fetch_service_instance_bindings_in_space(@service_instance.guid, space.guid).select(:app_guid).distinct.count
419419
CloudController::Presenters::V2::ServiceInstanceSharedToPresenter.new.to_hash(space, bound_app_count)
420420
end
421421
end

app/models/services/service_binding.rb

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,28 +35,11 @@ class ServiceBinding < Sequel::Model
3535

3636
delegate :service, :service_plan, to: :service_instance
3737

38-
def around_save
39-
yield
40-
rescue Sequel::UniqueConstraintViolation => e
41-
if e.message.include?('unique_service_binding_app_guid_name')
42-
errors.add(%i[app_guid name], :unique)
43-
raise validation_failed_error
44-
elsif e.message.include?('unique_service_binding_service_instance_guid_app_guid')
45-
errors.add(%i[service_instance_guid app_guid], :unique)
46-
raise validation_failed_error
47-
else
48-
raise e
49-
end
50-
end
51-
5238
def validate
5339
validates_presence :app
5440
validates_presence :service_instance
5541
validates_presence :type
5642

57-
validates_unique %i[app_guid service_instance_guid], message: Sequel.lit('The app is already bound to the service.')
58-
validates_unique %i[app_guid name], message: Sequel.lit("The binding name is invalid. App binding names must be unique. The app already has a binding with name '#{name}'.")
59-
6043
validate_space_match
6144
validate_cannot_change_binding
6245

app/models/services/service_instance.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def as_summary_json
148148
{
149149
'guid' => guid,
150150
'name' => name,
151-
'bound_app_count' => service_bindings_dataset.count,
151+
'bound_app_count' => service_bindings_dataset.select(:app_guid).distinct.count,
152152
'type' => type
153153
}
154154
end

app/presenters/system_environment/system_env_presenter.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,19 @@ def vcap_services
2222
private
2323

2424
def service_binding_env_variables
25+
latest_bindings = @service_bindings.
26+
select(&:create_succeeded?).
27+
group_by(&:service_instance_guid).
28+
values.
29+
map { |list| list.max_by(&:created_at) }
30+
2531
services_hash = {}
26-
@service_bindings.select(&:create_succeeded?).each do |service_binding|
32+
latest_bindings.each do |service_binding|
2733
service_name = service_binding_label(service_binding)
2834
services_hash[service_name.to_sym] ||= []
2935
services_hash[service_name.to_sym] << service_binding_env_values(service_binding)
3036
end
37+
3138
services_hash
3239
end
3340

lib/cloud_controller/diego/service_binding_files_builder.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,21 @@ def build
2727

2828
private
2929

30+
# rubocop:disable Metrics/CyclomaticComplexity
3031
def build_service_binding_k8s
3132
return nil unless @service_binding_k8s_enabled
3233

3334
service_binding_files = {}
3435
names = Set.new # to check for duplicate binding names
3536
total_bytesize = 0 # to check the total bytesize
3637

37-
@service_bindings.select(&:create_succeeded?).each do |service_binding|
38+
latest_bindings = @service_bindings.
39+
select(&:create_succeeded?).
40+
group_by(&:service_instance_guid).
41+
values.
42+
map { |list| list.max_by(&:created_at) }
43+
44+
latest_bindings.each do |service_binding|
3845
sb_hash = ServiceBindingPresenter.new(service_binding, include_instance: true).to_hash
3946
name = sb_hash[:name]
4047
raise IncompatibleBindings.new("Invalid binding name: '#{name}'. Name must match #{binding_naming_convention.inspect}") unless valid_binding_name?(name)
@@ -52,6 +59,7 @@ def build_service_binding_k8s
5259
total_bytesize += add_file(service_binding_files, name, 'type', label)
5360
total_bytesize += add_file(service_binding_files, name, 'provider', label)
5461
end
62+
# rubocop:enable Metrics/CyclomaticComplexity
5563

5664
raise IncompatibleBindings.new("Bindings exceed the maximum allowed bytesize of #{MAX_ALLOWED_BYTESIZE}: #{total_bytesize}") if total_bytesize > MAX_ALLOWED_BYTESIZE
5765

spec/request/service_credential_bindings_spec.rb

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,18 +1274,22 @@ def check_filtered_bindings(*bindings)
12741274
}))
12751275
end
12761276

1277-
it 'returns 422 when the binding already exists' do
1278-
api_call.call admin_headers
1279-
expect(last_response.status).to eq(201).or eq(202)
1277+
context 'when only one binding per app and service instance is allowed' do
1278+
before do
1279+
it 'returns 422 when the binding already exists' do
1280+
api_call.call admin_headers
1281+
expect(last_response.status).to eq(201).or eq(202)
12801282

1281-
api_call.call admin_headers
1283+
api_call.call admin_headers
12821284

1283-
expect(last_response).to have_status_code(422)
1284-
expect(parsed_response['errors']).to include(include({
1285-
'detail' => include('The app is already bound to the service instance'),
1286-
'title' => 'CF-UnprocessableEntity',
1287-
'code' => 10_008
1288-
}))
1285+
expect(last_response).to have_status_code(422)
1286+
expect(parsed_response['errors']).to include(include({
1287+
'detail' => include('The app is already bound to the service instance'),
1288+
'title' => 'CF-UnprocessableEntity',
1289+
'code' => 10_008
1290+
}))
1291+
end
1292+
end
12891293
end
12901294

12911295
context 'when the service instance does not exist' do

0 commit comments

Comments
 (0)