Skip to content

Commit fb31463

Browse files
philippthunsvkrieger
authored andcommitted
Add builder for service binding files
- validate binding names and (credential) keys - check for duplicate binding names - check the total bytesize, maximum allowed size is 1MB - files are added in the following order: 1. credential keys 2. VCAP_SERVICES attributes 3. 'type' and 'provider' - in case a credential key equals a VCAP_SERVICES attributes or 'type' or 'provider', it will be overwritten - for VCAP_SERVICES attribute names, underscores are replaced by hyphens - file content is serialized as JSON (non-string objects)
1 parent 9ee815a commit fb31463

File tree

2 files changed

+363
-0
lines changed

2 files changed

+363
-0
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
module VCAP::CloudController
2+
module Diego
3+
class ServiceBindingFilesBuilder
4+
class IncompatibleBindings < StandardError; end
5+
6+
MAX_ALLOWED_BYTESIZE = 1_000_000
7+
8+
def self.build(app_or_process)
9+
new(app_or_process).build
10+
end
11+
12+
def initialize(app_or_process)
13+
@file_based_service_bindings_enabled = app_or_process.file_based_service_bindings_enabled
14+
@service_bindings = app_or_process.service_bindings
15+
end
16+
17+
def build
18+
return nil unless @file_based_service_bindings_enabled
19+
20+
service_binding_files = {}
21+
names = Set.new # to check for duplicate binding names
22+
total_bytesize = 0 # to check the total bytesize
23+
24+
@service_bindings.select(&:create_succeeded?).each do |service_binding|
25+
sb_hash = ServiceBindingPresenter.new(service_binding, include_instance: true).to_hash
26+
name = sb_hash[:name]
27+
raise IncompatibleBindings.new("Invalid binding name: #{name}") unless valid_name?(name)
28+
raise IncompatibleBindings.new("Duplicate binding name: #{name}") if names.add?(name).nil?
29+
30+
# add the credentials first
31+
sb_hash.delete(:credentials)&.each { |k, v| total_bytesize += add_file(service_binding_files, name, k.to_s, v) }
32+
33+
# add the rest of the hash; already existing credential keys are overwritten
34+
# VCAP_SERVICES attribute names are transformed (e.g. binding_guid -> binding-guid)
35+
sb_hash.each { |k, v| total_bytesize += add_file(service_binding_files, name, transform_vcap_services_attribute(k.to_s), v) }
36+
37+
# add the type and provider
38+
label = sb_hash[:label]
39+
total_bytesize += add_file(service_binding_files, name, 'type', label)
40+
total_bytesize += add_file(service_binding_files, name, 'provider', label)
41+
end
42+
43+
raise IncompatibleBindings.new("Bindings exceed the maximum allowed bytesize of #{MAX_ALLOWED_BYTESIZE}: #{total_bytesize}") if total_bytesize > MAX_ALLOWED_BYTESIZE
44+
45+
service_binding_files.values
46+
end
47+
48+
private
49+
50+
# - adds a Diego::Bbs::Models::File object to the service_binding_files hash
51+
# - binding name is used as the directory name, key is used as the file name
52+
# - returns the bytesize of the path and content
53+
# - skips (and returns 0) if the value is nil or an empty array or hash
54+
# - serializes the value to JSON if it is a non-string object
55+
def add_file(service_binding_files, name, key, value)
56+
raise IncompatibleBindings.new("Invalid file name: #{key}") unless valid_name?(key)
57+
58+
path = "#{name}/#{key}"
59+
content = if value.nil?
60+
return 0
61+
elsif value.is_a?(String)
62+
value
63+
else
64+
return 0 if (value.is_a?(Array) || value.is_a?(Hash)) && value.empty?
65+
66+
Oj.dump(value, mode: :compat)
67+
end
68+
69+
service_binding_files[path] = ::Diego::Bbs::Models::File.new(path:, content:)
70+
path.bytesize + content.bytesize
71+
end
72+
73+
def valid_name?(name)
74+
name.match?(/^[a-z0-9\-.]{1,253}$/)
75+
end
76+
77+
def transform_vcap_services_attribute(name)
78+
if %w[binding_guid binding_name instance_guid instance_name syslog_drain_url volume_mounts].include?(name)
79+
name.tr('_', '-')
80+
else
81+
name
82+
end
83+
end
84+
end
85+
end
86+
end
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
require 'spec_helper'
2+
require 'cloud_controller/diego/service_binding_files_builder'
3+
4+
module VCAP::CloudController::Diego
5+
RSpec.shared_examples 'mapping of type and provider' do |label|
6+
it 'sets type and provider to the service label' do
7+
expect(service_binding_files.find { |f| f.path == "#{directory}/type" }).to have_attributes(content: label || 'service-name')
8+
expect(service_binding_files.find { |f| f.path == "#{directory}/provider" }).to have_attributes(content: label || 'service-name')
9+
expect(service_binding_files.find { |f| f.path == "#{directory}/label" }).to have_attributes(content: label || 'service-name')
10+
end
11+
end
12+
13+
RSpec.shared_examples 'mapping of binding metadata' do |name|
14+
it 'maps service binding metadata attributes to files' do
15+
expect(service_binding_files.find { |f| f.path == "#{directory}/binding-guid" }).to have_attributes(content: binding.guid)
16+
expect(service_binding_files.find { |f| f.path == "#{directory}/name" }).to have_attributes(content: name || 'binding-name')
17+
expect(service_binding_files.find { |f| f.path == "#{directory}/binding-name" }).to have_attributes(content: 'binding-name') if name.nil?
18+
end
19+
end
20+
21+
RSpec.shared_examples 'mapping of instance metadata' do |instance_name|
22+
it 'maps service instance metadata attributes to files' do
23+
expect(service_binding_files.find { |f| f.path == "#{directory}/instance-guid" }).to have_attributes(content: instance.guid)
24+
expect(service_binding_files.find { |f| f.path == "#{directory}/instance-name" }).to have_attributes(content: instance_name || 'instance-name')
25+
end
26+
end
27+
28+
RSpec.shared_examples 'mapping of plan metadata' do
29+
it 'maps service plan metadata attributes to files' do
30+
expect(service_binding_files.find { |f| f.path == "#{directory}/plan" }).to have_attributes(content: 'plan-name')
31+
end
32+
end
33+
34+
RSpec.shared_examples 'mapping of tags' do |tags|
35+
it 'maps (service tags merged with) instance tags to a file' do
36+
expect(service_binding_files.find do |f|
37+
f.path == "#{directory}/tags"
38+
end).to have_attributes(content: tags || '["a-service-tag","another-service-tag","an-instance-tag","another-instance-tag"]')
39+
end
40+
end
41+
42+
RSpec.shared_examples 'mapping of credentials' do |credential_files|
43+
it 'maps service binding credentials to individual files' do
44+
expected_credential_files = credential_files || {
45+
string: 'a string',
46+
number: '42',
47+
boolean: 'true',
48+
array: '["one","two","three"]',
49+
hash: '{"key":"value"}'
50+
}
51+
expected_credential_files.each do |name, content|
52+
expect(service_binding_files.find { |f| f.path == "#{directory}/#{name}" }).to have_attributes(content:)
53+
end
54+
end
55+
end
56+
57+
RSpec.shared_examples 'expected files' do |files|
58+
it 'does not include other files' do
59+
other_files = service_binding_files.reject do |file|
60+
match = file.path.match(%r{^#{directory}/(.+)$})
61+
!match.nil? && !files.delete(match[1]).nil?
62+
end
63+
64+
expect(files).to be_empty
65+
expect(other_files).to be_empty
66+
end
67+
end
68+
69+
RSpec.describe ServiceBindingFilesBuilder do
70+
let(:service) { VCAP::CloudController::Service.make(label: 'service-name', tags: %w[a-service-tag another-service-tag]) }
71+
let(:plan) { VCAP::CloudController::ServicePlan.make(name: 'plan-name', service: service) }
72+
let(:instance) { VCAP::CloudController::ManagedServiceInstance.make(name: 'instance-name', tags: %w[an-instance-tag another-instance-tag], service_plan: plan) }
73+
let(:binding_name) { 'binding-name' }
74+
let(:credentials) do
75+
{
76+
string: 'a string',
77+
number: 42,
78+
boolean: true,
79+
array: %w[one two three],
80+
hash: {
81+
key: 'value'
82+
}
83+
}
84+
end
85+
let(:syslog_drain_url) { nil }
86+
let(:volume_mounts) { nil }
87+
let(:binding) do
88+
VCAP::CloudController::ServiceBinding.make(
89+
name: binding_name,
90+
credentials: credentials,
91+
service_instance: instance,
92+
syslog_drain_url: syslog_drain_url,
93+
volume_mounts: volume_mounts
94+
)
95+
end
96+
let(:app) { binding.app }
97+
let(:directory) { 'binding-name' }
98+
99+
before do
100+
app.update(file_based_service_bindings_enabled: true)
101+
end
102+
103+
describe '#build' do
104+
subject(:build) { ServiceBindingFilesBuilder.build(app) }
105+
106+
it 'returns an array of Diego::Bbs::Models::File objects' do
107+
expect(build).to be_an(Array)
108+
expect(build).not_to be_empty
109+
expect(build).to all(be_a(Diego::Bbs::Models::File))
110+
end
111+
112+
describe 'mapping rules for service binding files' do
113+
subject(:service_binding_files) { build }
114+
115+
it 'puts all files into a directory named after the service binding' do
116+
expect(service_binding_files).not_to be_empty
117+
expect(service_binding_files).to all(have_attributes(path: match(%r{^binding-name/.+$})))
118+
end
119+
120+
include_examples 'mapping of type and provider'
121+
include_examples 'mapping of binding metadata'
122+
include_examples 'mapping of instance metadata'
123+
include_examples 'mapping of plan metadata'
124+
include_examples 'mapping of tags'
125+
include_examples 'mapping of credentials'
126+
127+
it 'omits null or empty array attributes' do
128+
expect(service_binding_files).not_to include(have_attributes(path: 'binding-name/syslog_drain_url'))
129+
expect(service_binding_files).not_to include(have_attributes(path: 'binding-name/volume_mounts'))
130+
end
131+
132+
include_examples 'expected files', %w[type provider label binding-guid name binding-name instance-guid instance-name plan tags string number boolean array hash]
133+
134+
context 'when binding_name is nil' do
135+
let(:binding_name) { nil }
136+
let(:directory) { 'instance-name' }
137+
138+
include_examples 'mapping of type and provider'
139+
include_examples 'mapping of binding metadata', 'instance-name'
140+
include_examples 'mapping of instance metadata'
141+
include_examples 'mapping of plan metadata'
142+
include_examples 'mapping of tags'
143+
include_examples 'mapping of credentials'
144+
145+
include_examples 'expected files', %w[type provider label binding-guid name instance-guid instance-name plan tags string number boolean array hash]
146+
end
147+
148+
context 'when syslog_drain_url is set' do
149+
let(:syslog_drain_url) { 'https://syslog.drain' }
150+
151+
it 'maps the attribute to a file' do
152+
expect(service_binding_files.find { |f| f.path == 'binding-name/syslog-drain-url' }).to have_attributes(content: 'https://syslog.drain')
153+
end
154+
155+
include_examples 'mapping of type and provider'
156+
include_examples 'mapping of binding metadata'
157+
include_examples 'mapping of instance metadata'
158+
include_examples 'mapping of plan metadata'
159+
include_examples 'mapping of tags'
160+
include_examples 'mapping of credentials'
161+
162+
include_examples 'expected files',
163+
%w[type provider label binding-guid name binding-name instance-guid instance-name plan tags string number boolean array hash syslog-drain-url]
164+
end
165+
166+
context 'when volume_mounts is set' do
167+
let(:volume_mounts) do
168+
[{
169+
container_dir: 'dir1',
170+
device_type: 'type1',
171+
mode: 'mode1',
172+
foo: 'bar'
173+
}, {
174+
container_dir: 'dir2',
175+
device_type: 'type2',
176+
mode: 'mode2',
177+
foo: 'baz'
178+
}]
179+
end
180+
181+
it 'maps the attribute to a file' do
182+
expect(service_binding_files.find do |f|
183+
f.path == 'binding-name/volume-mounts'
184+
end).to have_attributes(content: '[{"container_dir":"dir1","device_type":"type1","mode":"mode1"},{"container_dir":"dir2","device_type":"type2","mode":"mode2"}]')
185+
end
186+
187+
include_examples 'mapping of type and provider'
188+
include_examples 'mapping of binding metadata'
189+
include_examples 'mapping of instance metadata'
190+
include_examples 'mapping of plan metadata'
191+
include_examples 'mapping of tags'
192+
include_examples 'mapping of credentials'
193+
194+
include_examples 'expected files',
195+
%w[type provider label binding-guid name binding-name instance-guid instance-name plan tags string number boolean array hash volume-mounts]
196+
end
197+
198+
context 'when the instance is user-provided' do
199+
let(:instance) { VCAP::CloudController::UserProvidedServiceInstance.make(name: 'upsi', tags: %w[an-upsi-tag another-upsi-tag]) }
200+
201+
include_examples 'mapping of type and provider', 'user-provided'
202+
include_examples 'mapping of binding metadata'
203+
include_examples 'mapping of instance metadata', 'upsi'
204+
include_examples 'mapping of tags', '["an-upsi-tag","another-upsi-tag"]'
205+
include_examples 'mapping of credentials'
206+
207+
include_examples 'expected files', %w[type provider label binding-guid name binding-name instance-guid instance-name tags string number boolean array hash]
208+
end
209+
210+
context 'when there are duplicate keys at different levels' do
211+
let(:credentials) { { type: 'duplicate-type', name: 'duplicate-name', credentials: { password: 'secret' } } }
212+
213+
include_examples 'mapping of type and provider' # no 'duplicate-type'
214+
include_examples 'mapping of binding metadata' # no 'duplicate-name'
215+
include_examples 'mapping of instance metadata'
216+
include_examples 'mapping of plan metadata'
217+
include_examples 'mapping of tags'
218+
include_examples 'mapping of credentials', { credentials: '{"password":"secret"}' }
219+
220+
include_examples 'expected files', %w[type provider label binding-guid name binding-name instance-guid instance-name plan tags credentials]
221+
end
222+
223+
context 'when there are duplicate binding names' do
224+
let(:binding_name) { 'duplicate-name' }
225+
226+
before do
227+
VCAP::CloudController::ServiceBinding.make(app: app,
228+
service_instance: VCAP::CloudController::UserProvidedServiceInstance.make(
229+
space: app.space, name: 'duplicate-name'
230+
))
231+
end
232+
233+
it 'raises an exception' do
234+
expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, 'Duplicate binding name: duplicate-name')
235+
end
236+
end
237+
238+
context 'when binding names violate the Service Binding Specification for Kubernetes' do
239+
let(:binding_name) { 'binding_name' }
240+
241+
it 'raises an exception' do
242+
expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, 'Invalid binding name: binding_name')
243+
end
244+
end
245+
246+
context 'when the bindings exceed the maximum allowed bytesize' do
247+
let(:xxl_credentials) do
248+
c = {}
249+
value = 'v' * 1000
250+
1000.times do |i|
251+
c["key#{i}"] = value
252+
end
253+
c
254+
end
255+
256+
before do
257+
allow_any_instance_of(ServiceBindingPresenter).to receive(:to_hash).and_wrap_original do |original|
258+
original.call.merge(credentials: xxl_credentials)
259+
end
260+
end
261+
262+
it 'raises an exception' do
263+
expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, /^Bindings exceed the maximum allowed bytesize of 1000000: \d+/)
264+
end
265+
end
266+
267+
context 'when credential keys violate the Service Binding Specification for Kubernetes for binding entry file names' do
268+
let(:credentials) { { '../secret': 'hidden' } }
269+
270+
it 'raises an exception' do
271+
expect { service_binding_files }.to raise_error(ServiceBindingFilesBuilder::IncompatibleBindings, 'Invalid file name: ../secret')
272+
end
273+
end
274+
end
275+
end
276+
end
277+
end

0 commit comments

Comments
 (0)