hula 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,308 @@
1
+ # Copyright (c) 2014-2015 Pivotal Software, Inc.
2
+ # All rights reserved.
3
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
4
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
5
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
6
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
7
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
8
+ # USE OR OTHER DEALINGS IN THE SOFTWARE.
9
+ #
10
+
11
+ require 'tmpdir'
12
+ require 'json'
13
+ require 'open3'
14
+ require 'tempfile'
15
+
16
+ require 'hula/command_runner'
17
+ require 'hula/cloud_foundry/service_broker'
18
+
19
+ module Hula
20
+ class CloudFoundry
21
+ attr_reader :current_organization, :current_space, :domain, :api_url
22
+
23
+ def initialize(args)
24
+ @domain = args.fetch(:domain)
25
+ @api_url = args.fetch(:api_url)
26
+ @logger = args.fetch(:logger, default_logger)
27
+ @command_runner = args.fetch(:command_runner, default_command_runner)
28
+
29
+ target_and_login = args.fetch(:target_and_login, true)
30
+ if target_and_login
31
+ target(api_url)
32
+ login(args.fetch(:username), args.fetch(:password))
33
+ end
34
+ end
35
+
36
+ def url_for_app(app_name)
37
+ "https://#{app_name}.#{domain}"
38
+ end
39
+
40
+ def target(cloud_controller_url)
41
+ cf("api #{cloud_controller_url} --skip-ssl-validation")
42
+ end
43
+
44
+ def app_vcap_services(app_name)
45
+ app_environment(app_name)["VCAP_SERVICES"]
46
+ end
47
+
48
+ def login(username, password, allow_failure = true)
49
+ cf("auth #{username} #{password}", allow_failure: allow_failure)
50
+ end
51
+
52
+ def service_brokers
53
+ output = cf('service-brokers')
54
+
55
+ if output.include?('No service brokers found')
56
+ []
57
+ else
58
+ output.split("\n").drop(3).map do |row|
59
+ name, url = row.split(/\s+/)
60
+ ServiceBroker.new(name: name, url: url)
61
+ end
62
+ end
63
+ end
64
+
65
+ def create_and_target_org(name)
66
+ create_org(name)
67
+ sleep 1
68
+ target_org(name)
69
+ end
70
+
71
+ def create_org(name)
72
+ cf("create-org #{name}")
73
+ end
74
+
75
+ def target_org(name)
76
+ cf("target -o #{name}")
77
+ @current_organization = name
78
+ end
79
+
80
+ def create_and_target_space(name)
81
+ create_space(name)
82
+ target_space(name)
83
+ end
84
+
85
+ def create_space(name)
86
+ cf("create-space #{name}")
87
+ end
88
+
89
+ def target_space(name)
90
+ cf("target -s #{name}")
91
+ @current_space = name
92
+ end
93
+
94
+ def space_exists?(name)
95
+ spaces = cf('spaces').lines[3..-1]
96
+ spaces.map(&:strip).include?(name)
97
+ end
98
+
99
+ def org_exists?(name)
100
+ orgs = cf('orgs').lines[3..-1]
101
+ orgs.map(&:strip).include?(name)
102
+ end
103
+
104
+ def setup_permissive_security_group(org, space)
105
+ rules = [{
106
+ 'destination' => '0.0.0.0-255.255.255.255',
107
+ 'protocol' => 'all'
108
+ }]
109
+
110
+ rule_file = Tempfile.new('default_security_group.json')
111
+ rule_file.write(rules.to_json)
112
+ rule_file.close
113
+
114
+ cf("create-security-group prof-test #{rule_file.path}")
115
+ cf("bind-security-group prof-test #{org} #{space}")
116
+ cf('bind-staging-security-group prof-test')
117
+ cf('bind-running-security-group prof-test')
118
+
119
+ rule_file.unlink
120
+ end
121
+
122
+ def delete_space(name, options = {})
123
+ allow_failure = options.fetch(:allow_failure, true)
124
+ cf("delete-space #{name} -f", allow_failure: allow_failure)
125
+ end
126
+
127
+ def delete_org(name, options = {})
128
+ allow_failure = options.fetch(:allow_failure, true)
129
+ cf("delete-org #{name} -f", allow_failure: allow_failure)
130
+ end
131
+
132
+ alias_method :reset!, :delete_org
133
+
134
+ def add_public_service_broker(service_name, _service_label, url, username, password)
135
+ cf("create-service-broker #{service_name} #{username} #{password} #{url}")
136
+
137
+ service_plans = JSON.parse(cf('curl /v2/service_plans -X GET'))
138
+ guids = service_plans['resources'].map do |resource|
139
+ resource['metadata']['guid']
140
+ end
141
+
142
+ guids.each do |guid|
143
+ cf(%(curl /v2/service_plans/#{guid} -X PUT -d '{"public":true}'))
144
+ end
145
+ end
146
+
147
+ def remove_service_broker(service_name, options = {})
148
+ allow_failure = options.fetch(:allow_failure, true)
149
+ cf("delete-service-broker #{service_name} -f", allow_failure: allow_failure)
150
+ end
151
+
152
+ def assert_broker_is_in_marketplace(type)
153
+ output = marketplace
154
+ unless output.include?(type)
155
+ fail "Broker #{type} not found in marketplace"
156
+ end
157
+ end
158
+
159
+ def marketplace
160
+ cf('marketplace')
161
+ end
162
+
163
+ def create_service_instance(type, name, plan)
164
+ cf("create-service #{type} #{plan} #{name}")
165
+ end
166
+
167
+ def delete_service_instance_and_unbind(name, options = {})
168
+ allow_failure = options.fetch(:allow_failure, true)
169
+ cf("delete-service -f #{name}", allow_failure: allow_failure)
170
+ end
171
+
172
+ def assert_instance_is_in_services_list(service_name)
173
+ output = cf('services')
174
+ unless output.include?(service_name)
175
+ fail "Instance #{service_name} not found in services list"
176
+ end
177
+ end
178
+
179
+ def push_app_and_start(app_path, name)
180
+ push_app(app_path, name)
181
+ start_app(name)
182
+ end
183
+
184
+ def push_app(app_path, name)
185
+ cf("push #{name} -p #{app_path} -n #{name} -d #{domain} --no-start")
186
+ end
187
+
188
+ def enable_diego_for_app(name)
189
+ cf("enable-diego #{name}")
190
+ end
191
+
192
+ def delete_app(name, options = {})
193
+ allow_failure = options.fetch(:allow_failure, true)
194
+ cf("delete #{name} -f", allow_failure: allow_failure)
195
+ end
196
+
197
+ def bind_app_to_service(app_name, service_name)
198
+ cf("bind-service #{app_name} #{service_name}")
199
+ end
200
+
201
+ def unbind_app_from_service(app_name, service_name)
202
+ cf("unbind-service #{app_name} #{service_name}")
203
+ end
204
+
205
+ def list_service_keys(service_instance_name)
206
+ cf("service-keys #{service_instance_name}")
207
+ end
208
+
209
+ def create_service_key(service_instance_name, key_name)
210
+ cf("create-service-key #{service_instance_name} #{key_name}")
211
+ end
212
+
213
+ def delete_service_key(service_instance_name, key_name)
214
+ cf("delete-service-key #{service_instance_name} #{key_name} -f")
215
+ end
216
+
217
+ def service_key(service_instance_name, key_name)
218
+ cf("service-key #{service_instance_name} #{key_name}")
219
+ end
220
+
221
+ def restart_app(name)
222
+ stop_app(name)
223
+ start_app(name)
224
+ end
225
+
226
+ def start_app(name)
227
+ cf("start #{name}")
228
+ rescue => start_exception
229
+ begin
230
+ cf("logs --recent #{name}")
231
+ ensure
232
+ raise start_exception
233
+ end
234
+ end
235
+
236
+ def stop_app(name)
237
+ cf("stop #{name}")
238
+ end
239
+
240
+ def app_env(app_name)
241
+ cf("env #{app_name}")
242
+ end
243
+
244
+ def create_user(username, password)
245
+ cf("create-user #{username} #{password}")
246
+ end
247
+
248
+ def delete_user(username)
249
+ cf("delete-user -f #{username}")
250
+ end
251
+
252
+ def user_exists?(username, org)
253
+ output = cf("org-users #{org}")
254
+ output.lines.select { |l| l.start_with? ' ' }.map(&:strip).uniq.include?(username)
255
+ end
256
+
257
+ def set_org_role(username, org, role)
258
+ cf("set-org-role #{username} #{org} #{role}")
259
+ end
260
+
261
+ def version
262
+ cf('-v')
263
+ end
264
+
265
+ private
266
+
267
+ attr_reader :logger, :command_runner
268
+
269
+ def app_environment(app_name)
270
+ env_output = cf("env #{app_name}")
271
+ response = env_output[/^\{.*}$/m].split(/^\n/)
272
+ response = response.map { |json| JSON.parse(json) }
273
+ response.inject({}) { |result, current| result.merge(current) }
274
+ end
275
+
276
+ def default_logger
277
+ @default_logger ||= begin
278
+ STDOUT.sync = true
279
+ require 'logger'
280
+ Logger.new(STDOUT)
281
+ end
282
+ end
283
+
284
+ def default_command_runner
285
+ @default_command_runner ||= CommandRunner.new(environment: env)
286
+ end
287
+
288
+ def cf(command, options = {})
289
+ allow_failure = options.fetch(:allow_failure, false)
290
+ cf_command = "cf #{command}"
291
+
292
+ logger.info(cf_command)
293
+
294
+ command_runner.run(cf_command, allow_failure: allow_failure)
295
+ end
296
+
297
+ def env
298
+ @env ||= ENV.to_hash.merge(
299
+ 'PATH' => clean_path,
300
+ 'CF_HOME' => Dir.mktmpdir('cf-home')
301
+ )
302
+ end
303
+
304
+ def clean_path
305
+ '/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin/:sbin'
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,38 @@
1
+ # Copyright (c) 2014-2015 Pivotal Software, Inc.
2
+ # All rights reserved.
3
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
4
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
5
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
6
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
7
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
8
+ # USE OR OTHER DEALINGS IN THE SOFTWARE.
9
+ #
10
+
11
+ require 'open3'
12
+
13
+ module Hula
14
+ class CommandFailedError < StandardError; end
15
+
16
+ class CommandRunner
17
+ def initialize(environment: ENV)
18
+ @environment = environment
19
+ end
20
+
21
+ def run(command, allow_failure: false)
22
+ stdout_and_stderr, status = Open3.capture2e(environment, command)
23
+
24
+ if !allow_failure && !status.success?
25
+ message = "Command failed! - #{command}\n\n#{stdout_and_stderr}\n\nexit status: #{status.exitstatus}"
26
+ fail CommandFailedError, message
27
+ end
28
+
29
+ stdout_and_stderr
30
+ rescue => e
31
+ raise CommandFailedError, e
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :environment
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ # Copyright (c) 2014-2015 Pivotal Software, Inc.
2
+ # All rights reserved.
3
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
4
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
5
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
6
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
7
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
8
+ # USE OR OTHER DEALINGS IN THE SOFTWARE.
9
+ #
10
+
11
+ require 'hula/helpers/timeout_tools'
12
+
13
+ require 'socket'
14
+
15
+ module Hula
16
+ module Helpers
17
+ module SocketTools
18
+ module_function def wait_for_port(host:, port:, timeout_seconds: 20)
19
+ error = "Failed to connect to #{host}:#{port} within #{timeout_seconds} seconds"
20
+ TimeoutTools.wait_for(error: error, timeout_seconds: timeout_seconds) do
21
+ port_open?(host: host, port: port)
22
+ end
23
+ end
24
+
25
+ module_function def port_open?(host:, port:)
26
+ socket = TCPSocket.new(host, port)
27
+ socket.close unless socket.nil?
28
+ true
29
+ rescue Errno::ECONNREFUSED
30
+ false
31
+ end
32
+
33
+ module_function def free_port
34
+ socket = Socket.new(:INET, :STREAM, 0)
35
+ socket.bind(Addrinfo.tcp('127.0.0.1', 0))
36
+ socket.local_address.ip_port
37
+ ensure
38
+ socket.close
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+
@@ -0,0 +1,27 @@
1
+ # Copyright (c) 2014-2015 Pivotal Software, Inc.
2
+ # All rights reserved.
3
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
4
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
5
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
6
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
7
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
8
+ # USE OR OTHER DEALINGS IN THE SOFTWARE.
9
+ #
10
+
11
+ require 'timeout'
12
+
13
+ module Hula
14
+ module Helpers
15
+ module TimeoutTools
16
+ module_function def wait_for(error: nil, timeout_seconds:, &condition_block)
17
+ Timeout::timeout(timeout_seconds) do
18
+ until condition_block.call do
19
+ sleep 0.1
20
+ end
21
+ end
22
+ rescue Timeout::Error => e
23
+ error ? raise(error) : raise(e)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,72 @@
1
+ # Copyright (c) 2014-2015 Pivotal Software, Inc.
2
+ # All rights reserved.
3
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
4
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
5
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
6
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
7
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
8
+ # USE OR OTHER DEALINGS IN THE SOFTWARE.
9
+ #
10
+
11
+ require 'hula/helpers/socket_tools'
12
+
13
+ module Hula
14
+ class HttpProxyUpstreamSocks
15
+ include Helpers::SocketTools
16
+
17
+ def initialize(
18
+ polipo_bin: 'polipo',
19
+ socks_proxy:,
20
+ http_host: 'localhost',
21
+ http_port: free_port
22
+ )
23
+ @socks_proxy_host = socks_proxy.socks_host
24
+ @socks_proxy_port = socks_proxy.socks_port
25
+ @http_host = http_host
26
+ @http_port = http_port
27
+ @polipo_bin = polipo_bin
28
+
29
+ check_polipo_bin!
30
+ end
31
+
32
+ attr_reader :http_host, :http_port
33
+
34
+ def start
35
+ @process ||= start_polipo_process
36
+ end
37
+
38
+ def stop
39
+ return unless @process
40
+
41
+ Process.kill('TERM', @process) rescue Errno::ESRCH
42
+ Process.wait(@process) rescue Errno::ECHILD
43
+ @process = nil
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :socks_proxy_host, :socks_proxy_port, :polipo_bin
49
+
50
+
51
+ def start_polipo_process
52
+ pid = Process.spawn(polipo_command)
53
+ at_exit { stop }
54
+ wait_for_port(host: http_host, port: http_port)
55
+ Process.detach(pid)
56
+ pid
57
+ end
58
+
59
+ def polipo_command
60
+ "#{polipo_bin} diskCacheRoot='' \
61
+ proxyPort=#{http_port} \
62
+ socksParentProxy=#{socks_proxy_host}:#{socks_proxy_port} \
63
+ socksProxyType=socks4a"
64
+ end
65
+
66
+ def check_polipo_bin!
67
+ unless system("which #{polipo_bin} > /dev/null 2>&1")
68
+ raise "Could not run polipo (#{polipo_bin}). Please install, or put in PATH"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,123 @@
1
+ # Copyright (c) 2014-2015 Pivotal Software, Inc.
2
+ # All rights reserved.
3
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
4
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
5
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
6
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
7
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
8
+ # USE OR OTHER DEALINGS IN THE SOFTWARE.
9
+ #
10
+
11
+ require 'forwardable'
12
+ require 'hula/service_broker/catalog'
13
+ require 'hula/service_broker/instance_binding'
14
+ require 'hula/service_broker/http_json_client'
15
+ require 'hula/service_broker/service_instance'
16
+
17
+ require 'securerandom'
18
+
19
+ module Hula
20
+ module ServiceBroker
21
+ class Api
22
+ extend Forwardable
23
+ def_delegators :catalog, :service_plan
24
+
25
+ def initialize(url:, username:, password:, http_client: HttpJsonClient.new)
26
+ @http_client = http_client
27
+
28
+ @url = URI(url)
29
+ @username = username
30
+ @password = password
31
+ end
32
+
33
+ attr_reader :url
34
+
35
+ def catalog
36
+ json = http_client.get(url_for('/v2/catalog'), auth: { username: username, password: password })
37
+ Catalog.new(json)
38
+ end
39
+
40
+ def provision_instance(plan, service_instance_id: SecureRandom.uuid)
41
+ http_provision_instance(
42
+ service_id: plan.service_id,
43
+ plan_id: plan.id,
44
+ service_instance_id: service_instance_id
45
+ )
46
+
47
+ ServiceInstance.new(id: service_instance_id)
48
+ end
49
+
50
+ def deprovision_instance(service_instance)
51
+ http_deprovision_service(service_instance_id: service_instance.id)
52
+ end
53
+
54
+ def bind_instance(service_instance, binding_id: SecureRandom.uuid)
55
+ result = http_bind_instance(
56
+ service_instance_id: service_instance.id,
57
+ binding_id: binding_id
58
+ )
59
+
60
+ InstanceBinding.new(
61
+ id: binding_id,
62
+ credentials: result.fetch(:credentials),
63
+ service_instance: service_instance
64
+ )
65
+ end
66
+
67
+ def unbind_instance(instance_binding)
68
+ http_unbind_instance(
69
+ service_instance_id: instance_binding.service_instance.id,
70
+ binding_id: instance_binding.id
71
+ )
72
+ end
73
+
74
+ def debug
75
+ http_client.get(url_for('/debug'), auth: { username: username, password: password })
76
+ end
77
+
78
+ private
79
+
80
+ def http_provision_instance(service_instance_id:, service_id:, plan_id:)
81
+ http_client.put(
82
+ url_for("/v2/service_instances/#{service_instance_id}"),
83
+ body: {
84
+ service_id: service_id,
85
+ plan_id: plan_id,
86
+ },
87
+ auth: { username: username, password: password }
88
+ )
89
+ end
90
+
91
+ def http_deprovision_service(service_instance_id:)
92
+ http_client.delete(
93
+ url_for("/v2/service_instances/#{service_instance_id}"),
94
+ auth: {
95
+ username: username,
96
+ password: password
97
+ }
98
+ )
99
+ end
100
+
101
+ def http_bind_instance(service_instance_id:, binding_id:)
102
+ http_client.put(
103
+ url_for("/v2/service_instances/#{service_instance_id}/service_bindings/#{binding_id}"),
104
+ body: {},
105
+ auth: { username: username, password: password }
106
+ )
107
+ end
108
+
109
+ def http_unbind_instance(service_instance_id:, binding_id:)
110
+ http_client.delete(
111
+ url_for("/v2/service_instances/#{service_instance_id}/service_bindings/#{binding_id}"),
112
+ auth: { username: username, password: password }
113
+ )
114
+ end
115
+
116
+ def url_for(path)
117
+ url.dup.tap { |uri| uri.path += path }
118
+ end
119
+
120
+ attr_reader :http_client, :username, :password
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,43 @@
1
+ # Copyright (c) 2014-2015 Pivotal Software, Inc.
2
+ # All rights reserved.
3
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
4
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
5
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
6
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
7
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
8
+ # USE OR OTHER DEALINGS IN THE SOFTWARE.
9
+ #
10
+
11
+ require 'hula/service_broker/errors'
12
+ require 'hula/service_broker/service'
13
+
14
+ module Hula
15
+ module ServiceBroker
16
+
17
+ class Catalog
18
+ attr_reader :services
19
+
20
+ def initialize(args = {})
21
+ @services = args.fetch(:services).map { |s| Service.new(s) }
22
+ end
23
+
24
+ def ==(other)
25
+ is_a?(other.class) &&
26
+ services == other.services
27
+ end
28
+
29
+ def service(service_name)
30
+ services.find { |s| s.name == service_name } or
31
+ fail(ServiceNotFoundError, [
32
+ %{Unknown service with name: #{service_name.inspect}},
33
+ " Known service names: #{services.map(&:name).inspect}"
34
+ ].join("\n")
35
+ )
36
+ end
37
+
38
+ def service_plan(service_name, plan_name)
39
+ service(service_name).plan(plan_name)
40
+ end
41
+ end
42
+ end
43
+ end