Skip to content

Commit 8a422ed

Browse files
committed
Allow multiple service bindings
Allows operators to set the number of allowed service bindings via parameter `max_service_credential_bindings_per_app_service_instance`. This will be set to 1 in capi-release to not change the current behaviour. 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. Further details can be found in RFC-0040: https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0040-service-binding-rotation.md
1 parent bb92b9e commit 8a422ed

File tree

14 files changed

+326
-54
lines changed

14 files changed

+326
-54
lines changed

app/actions/service_credential_binding_app_create.rb

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,37 +19,44 @@ 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+
validate_app_guid_name_uniqueness!(app.guid, message.name, service_instance.guid) # if max_bindings_per_app_service_instance == 1
34+
35+
num_valid_bindings = 0
36+
existing_bindings = ServiceBinding.where(service_instance:, app:)
37+
existing_bindings.each do |binding|
38+
binding.lock!
39+
40+
if binding.create_failed?
41+
binding.destroy
42+
VCAP::Services::ServiceBrokers::V2::OrphanMitigator.new.cleanup_failed_bind(binding)
43+
next
44+
end
45+
46+
validate_binding!(binding, desired_binding_name: message.name)
47+
num_valid_bindings += 1
3848
end
39-
b.save_with_attributes_and_new_operation(
40-
binding_details,
49+
50+
validate_number_of_bindings!(num_valid_bindings)
51+
52+
new_binding.save_with_attributes_and_new_operation(
53+
new_binding_details,
4154
CREATE_INITIAL_OPERATION
4255
)
43-
MetadataUpdate.update(b, message)
56+
MetadataUpdate.update(new_binding, message)
4457
end
4558
end
4659
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-
5360
raise UnprocessableCreate.new(e.full_message)
5461
end
5562

@@ -66,11 +73,25 @@ def validate_service_instance!(app, service_instance, volume_mount_services_enab
6673
operation_in_progress! if service_instance.operation_in_progress?
6774
end
6875

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

72-
already_bound! if binding.create_succeeded? || binding.create_in_progress?
73-
incomplete_deletion! if binding.delete_failed? || binding.delete_in_progress?
93+
name_uniqueness_violation!(desired_binding_name) if max_bindings_per_app_service_instance == 1 && dataset.first
94+
name_uniqueness_violation!(desired_binding_name) if dataset.exclude(service_instance_guid: target_service_instance_guid).first
7495
end
7596

7697
def permitted_binding_attributes
@@ -87,6 +108,10 @@ def event_repository
87108
)
88109
end
89110

111+
def max_bindings_per_app_service_instance
112+
VCAP::CloudController::Config.config.get(:max_service_credential_bindings_per_app_service_instance)
113+
end
114+
90115
def app_is_required!
91116
raise UnprocessableCreate.new('No app was specified')
92117
end
@@ -95,6 +120,27 @@ def not_supported!
95120
raise Unimplemented.new('Cannot create credential bindings for managed service instances')
96121
end
97122

123+
def binding_in_progress!(binding_guid)
124+
raise UnprocessableCreate.new("There is already a binding in progress for this service instance and app (binding guid: #{binding_guid})")
125+
end
126+
127+
def too_many_bindings!
128+
raise UnprocessableCreate.new(
129+
"The app has too many bindings to this service instance (limit: #{max_bindings_per_app_service_instance}). Consider deleting existing/orphaned bindings."
130+
)
131+
end
132+
133+
def name_cannot_be_changed!
134+
raise UnprocessableCreate.new('The binding name cannot be changed for the same app and service instance')
135+
end
136+
137+
def name_uniqueness_violation!(name)
138+
msg = 'The binding name is invalid. Binding names must be unique for a given service instance and app.'
139+
msg += " The app already has a binding with name '#{name}'." unless name.nil? || name.empty?
140+
141+
raise UnprocessableCreate.new(msg)
142+
end
143+
98144
def already_bound!
99145
raise UnprocessableCreate.new('The app is already bound to the service instance')
100146
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

config/cloud_controller.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ default_app_lifecycle: buildpack
392392
custom_metric_tag_prefix_list: ["metric.tag.cloudfoundry.org"]
393393

394394
max_manifest_service_binding_poll_duration_in_seconds: 60
395+
max_service_credential_bindings_per_app_service_instance: 5
395396
update_metric_tags_on_rename: true
396397

397398
app_log_revision: true
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
Sequel.migration do
2+
no_transaction # adding an index concurrently cannot be done within a transaction
3+
4+
up do
5+
transaction do
6+
alter_table :service_bindings do
7+
drop_constraint :unique_service_binding_service_instance_guid_app_guid if @db.indexes(:service_bindings).include?(:unique_service_binding_service_instance_guid_app_guid)
8+
9+
drop_constraint :unique_service_binding_app_guid_name if @db.indexes(:service_bindings).include?(:unique_service_binding_app_guid_name)
10+
end
11+
end
12+
13+
VCAP::Migration.with_concurrent_timeout(self) do
14+
add_index :service_bindings, %i[app_guid service_instance_guid], name: :service_bindings_app_guid_service_instance_guid_index, if_not_exists: true, concurrently: true
15+
end
16+
end
17+
18+
down do
19+
transaction do
20+
alter_table :service_bindings do
21+
unless @db.indexes(:service_bindings).include?(:unique_service_binding_service_instance_guid_app_guid)
22+
add_unique_constraint %i[service_instance_guid app_guid],
23+
name: :unique_service_binding_service_instance_guid_app_guid
24+
end
25+
add_unique_constraint %i[app_guid name], name: :unique_service_binding_app_guid_name unless @db.indexes(:service_bindings).include?(:unique_service_binding_app_guid_name)
26+
end
27+
end
28+
29+
VCAP::Migration.with_concurrent_timeout(self) do
30+
drop_index :service_bindings, %i[app_guid service_instance_guid], name: :service_bindings_app_guid_service_instance_guid_index, if_exists: true, concurrently: true
31+
end
32+
end
33+
end

lib/cloud_controller/config_schemas/api_schema.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,8 @@ class ApiSchema < VCAP::Config
409409
optional(:query_raise_on_mismatch) => bool
410410
},
411411

412+
max_service_credential_bindings_per_app_service_instance: Integer,
413+
412414
max_labels_per_resource: Integer,
413415
max_annotations_per_resource: Integer,
414416

lib/cloud_controller/config_schemas/worker_schema.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ class WorkerSchema < VCAP::Config
219219
route_services_enabled: bool,
220220

221221
max_manifest_service_binding_poll_duration_in_seconds: Integer,
222+
max_service_credential_bindings_per_app_service_instance: Integer,
222223

223224
max_labels_per_resource: Integer,
224225
max_annotations_per_resource: Integer,

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_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

0 commit comments

Comments
 (0)