Skip to content

Commit 4879337

Browse files
committed
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 - file content is serialized as JSON (non-string objects)
1 parent bfa798b commit 4879337

File tree

2 files changed

+343
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)