chef-infra-api 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/lib/chef-api.rb +96 -0
  4. data/lib/chef-api/aclable.rb +35 -0
  5. data/lib/chef-api/authentication.rb +300 -0
  6. data/lib/chef-api/boolean.rb +6 -0
  7. data/lib/chef-api/configurable.rb +80 -0
  8. data/lib/chef-api/connection.rb +507 -0
  9. data/lib/chef-api/defaults.rb +197 -0
  10. data/lib/chef-api/error_collection.rb +44 -0
  11. data/lib/chef-api/errors.rb +64 -0
  12. data/lib/chef-api/multipart.rb +164 -0
  13. data/lib/chef-api/resource.rb +21 -0
  14. data/lib/chef-api/resources/base.rb +960 -0
  15. data/lib/chef-api/resources/client.rb +84 -0
  16. data/lib/chef-api/resources/collection_proxy.rb +234 -0
  17. data/lib/chef-api/resources/cookbook.rb +24 -0
  18. data/lib/chef-api/resources/cookbook_version.rb +23 -0
  19. data/lib/chef-api/resources/data_bag.rb +136 -0
  20. data/lib/chef-api/resources/data_bag_item.rb +53 -0
  21. data/lib/chef-api/resources/environment.rb +16 -0
  22. data/lib/chef-api/resources/group.rb +16 -0
  23. data/lib/chef-api/resources/node.rb +20 -0
  24. data/lib/chef-api/resources/organization.rb +22 -0
  25. data/lib/chef-api/resources/partial_search.rb +44 -0
  26. data/lib/chef-api/resources/principal.rb +11 -0
  27. data/lib/chef-api/resources/role.rb +18 -0
  28. data/lib/chef-api/resources/search.rb +47 -0
  29. data/lib/chef-api/resources/user.rb +82 -0
  30. data/lib/chef-api/schema.rb +150 -0
  31. data/lib/chef-api/util.rb +119 -0
  32. data/lib/chef-api/validator.rb +16 -0
  33. data/lib/chef-api/validators/base.rb +82 -0
  34. data/lib/chef-api/validators/required.rb +11 -0
  35. data/lib/chef-api/validators/type.rb +23 -0
  36. data/lib/chef-api/version.rb +3 -0
  37. data/templates/errors/abstract_method.erb +5 -0
  38. data/templates/errors/cannot_regenerate_key.erb +1 -0
  39. data/templates/errors/chef_api_error.erb +1 -0
  40. data/templates/errors/file_not_found.erb +1 -0
  41. data/templates/errors/http_bad_request.erb +3 -0
  42. data/templates/errors/http_forbidden_request.erb +3 -0
  43. data/templates/errors/http_gateway_timeout.erb +3 -0
  44. data/templates/errors/http_method_not_allowed.erb +3 -0
  45. data/templates/errors/http_not_acceptable.erb +3 -0
  46. data/templates/errors/http_not_found.erb +3 -0
  47. data/templates/errors/http_server_unavailable.erb +1 -0
  48. data/templates/errors/http_unauthorized_request.erb +3 -0
  49. data/templates/errors/insufficient_file_permissions.erb +1 -0
  50. data/templates/errors/invalid_resource.erb +1 -0
  51. data/templates/errors/invalid_validator.erb +1 -0
  52. data/templates/errors/missing_url_parameter.erb +1 -0
  53. data/templates/errors/not_a_directory.erb +1 -0
  54. data/templates/errors/resource_already_exists.erb +1 -0
  55. data/templates/errors/resource_not_found.erb +1 -0
  56. data/templates/errors/resource_not_mutable.erb +1 -0
  57. data/templates/errors/unknown_attribute.erb +1 -0
  58. metadata +130 -0
@@ -0,0 +1,6 @@
1
+ module ChefAPI
2
+ module Boolean; end
3
+
4
+ TrueClass.send(:include, Boolean)
5
+ FalseClass.send(:include, Boolean)
6
+ end
@@ -0,0 +1,80 @@
1
+ module ChefAPI
2
+ #
3
+ # A re-usable class containing configuration information for the {Connection}.
4
+ # See {Defaults} for a list of default values.
5
+ #
6
+ module Configurable
7
+ class << self
8
+ #
9
+ # The list of configurable keys.
10
+ #
11
+ # @return [Array<Symbol>]
12
+ #
13
+ def keys
14
+ @keys ||= [
15
+ :endpoint,
16
+ :flavor,
17
+ :client,
18
+ :key,
19
+ :proxy_address,
20
+ :proxy_password,
21
+ :proxy_port,
22
+ :proxy_username,
23
+ :ssl_pem_file,
24
+ :ssl_verify,
25
+ :user_agent,
26
+ :read_timeout,
27
+ ]
28
+ end
29
+ end
30
+
31
+ #
32
+ # Create one attribute getter and setter for each key.
33
+ #
34
+ ChefAPI::Configurable.keys.each do |key|
35
+ attr_accessor key
36
+ end
37
+
38
+ #
39
+ # Set the configuration for this config, using a block.
40
+ #
41
+ # @example Configure the API endpoint
42
+ # ChefAPI.configure do |config|
43
+ # config.endpoint = "http://www.my-ChefAPI-server.com/ChefAPI"
44
+ # end
45
+ #
46
+ def configure
47
+ yield self
48
+ end
49
+
50
+ #
51
+ # Reset all configuration options to their default values.
52
+ #
53
+ # @example Reset all settings
54
+ # ChefAPI.reset!
55
+ #
56
+ # @return [self]
57
+ #
58
+ def reset!
59
+ ChefAPI::Configurable.keys.each do |key|
60
+ instance_variable_set(:"@#{key}", Defaults.options[key])
61
+ end
62
+ self
63
+ end
64
+ alias_method :setup, :reset!
65
+
66
+ private
67
+
68
+ #
69
+ # The list of configurable keys, as an options hash.
70
+ #
71
+ # @return [Hash]
72
+ #
73
+ def options
74
+ map = ChefAPI::Configurable.keys.map do |key|
75
+ [key, instance_variable_get(:"@#{key}")]
76
+ end
77
+ Hash[map]
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,507 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ require 'openssl'
4
+ require 'uri'
5
+
6
+ module ChefAPI
7
+ #
8
+ # Connection object for the ChefAPI API.
9
+ #
10
+ # @see https://docs.chef.io/api_chef_server.html
11
+ #
12
+ class Connection
13
+ class << self
14
+ #
15
+ # @private
16
+ #
17
+ # @macro proxy
18
+ # @method $1
19
+ # Get the list of $1 for this {Connection}. This method is threadsafe.
20
+ #
21
+ # @example Get the $1 from this {Connection} object
22
+ # connection = ChefAPI::Connection.new('...')
23
+ # connection.$1 #=> $2(attribute1, attribute2, ...)
24
+ #
25
+ # @return [Class<$2>]
26
+ #
27
+ def proxy(name, klass)
28
+ class_eval <<-EOH, __FILE__, __LINE__ + 1
29
+ def #{name}
30
+ Thread.current['chefapi.connection'] = self
31
+ #{klass}
32
+ end
33
+ EOH
34
+ end
35
+ end
36
+
37
+ include Logify
38
+ include ChefAPI::Configurable
39
+
40
+ proxy :clients, 'Resource::Client'
41
+ proxy :cookbooks, 'Resource::Cookbook'
42
+ proxy :data_bags, 'Resource::DataBag'
43
+ proxy :data_bag_item, 'Resource::DataBagItem'
44
+ proxy :environments, 'Resource::Environment'
45
+ proxy :groups, 'Resource::Group'
46
+ proxy :nodes, 'Resource::Node'
47
+ proxy :partial_search, 'Resource::PartialSearch'
48
+ proxy :principals, 'Resource::Principal'
49
+ proxy :roles, 'Resource::Role'
50
+ proxy :search, 'Resource::Search'
51
+ proxy :users, 'Resource::User'
52
+ proxy :organizations, 'Resource::Organization'
53
+
54
+ #
55
+ # Create a new ChefAPI Connection with the given options. Any options
56
+ # given take precedence over the default options.
57
+ #
58
+ # @example Create a connection object from a list of options
59
+ # ChefAPI::Connection.new(
60
+ # endpoint: 'https://...',
61
+ # client: 'bacon',
62
+ # key: '~/.chef/bacon.pem',
63
+ # )
64
+ #
65
+ # @example Create a connection object using a block
66
+ # ChefAPI::Connection.new do |connection|
67
+ # connection.endpoint = 'https://...'
68
+ # connection.client = 'bacon'
69
+ # connection.key = '~/.chef/bacon.pem'
70
+ # end
71
+ #
72
+ # @return [ChefAPI::Connection]
73
+ #
74
+ def initialize(options = {})
75
+ # Use any options given, but fall back to the defaults set on the module
76
+ ChefAPI::Configurable.keys.each do |key|
77
+ value = if options[key].nil?
78
+ ChefAPI.instance_variable_get(:"@#{key}")
79
+ else
80
+ options[key]
81
+ end
82
+
83
+ instance_variable_set(:"@#{key}", value)
84
+ end
85
+
86
+ yield self if block_given?
87
+ end
88
+
89
+ #
90
+ # Determine if the given options are the same as ours.
91
+ #
92
+ # @return [Boolean]
93
+ #
94
+ def same_options?(opts)
95
+ opts.hash == options.hash
96
+ end
97
+
98
+ #
99
+ # Make a HTTP GET request
100
+ #
101
+ # @param path (see Connection#request)
102
+ # @param [Hash] params
103
+ # the list of query params
104
+ # @param request_options (see Connection#request)
105
+ #
106
+ # @raise (see Connection#request)
107
+ # @return (see Connection#request)
108
+ #
109
+ def get(path, params = {}, request_options = {})
110
+ request(:get, path, params)
111
+ end
112
+
113
+ #
114
+ # Make a HTTP POST request
115
+ #
116
+ # @param path (see Connection#request)
117
+ # @param [String, #read] data
118
+ # the body to use for the request
119
+ # @param [Hash] params
120
+ # the list of query params
121
+ # @param request_options (see Connection#request)
122
+ #
123
+ # @raise (see Connection#request)
124
+ # @return (see Connection#request)
125
+ #
126
+ def post(path, data, params = {}, request_options = {})
127
+ request(:post, path, data, params)
128
+ end
129
+
130
+ #
131
+ # Make a HTTP PUT request
132
+ #
133
+ # @param path (see Connection#request)
134
+ # @param data (see Connection#post)
135
+ # @param params (see Connection#post)
136
+ # @param request_options (see Connection#request)
137
+ #
138
+ # @raise (see Connection#request)
139
+ # @return (see Connection#request)
140
+ #
141
+ def put(path, data, params = {}, request_options = {})
142
+ request(:put, path, data, params)
143
+ end
144
+
145
+ #
146
+ # Make a HTTP PATCH request
147
+ #
148
+ # @param path (see Connection#request)
149
+ # @param data (see Connection#post)
150
+ # @param params (see Connection#post)
151
+ # @param request_options (see Connection#request)
152
+ #
153
+ # @raise (see Connection#request)
154
+ # @return (see Connection#request)
155
+ #
156
+ def patch(path, data, params = {}, request_options = {})
157
+ request(:patch, path, data, params)
158
+ end
159
+
160
+ #
161
+ # Make a HTTP DELETE request
162
+ #
163
+ # @param path (see Connection#request)
164
+ # @param params (see Connection#get)
165
+ # @param request_options (see Connection#request)
166
+ #
167
+ # @raise (see Connection#request)
168
+ # @return (see Connection#request)
169
+ #
170
+ def delete(path, params = {}, request_options = {})
171
+ request(:delete, path, params)
172
+ end
173
+
174
+ #
175
+ # Make an HTTP request with the given verb, data, params, and headers. If
176
+ # the response has a return type of JSON, the JSON is automatically parsed
177
+ # and returned as a hash; otherwise it is returned as a string.
178
+ #
179
+ # @raise [Error::HTTPError]
180
+ # if the request is not an HTTP 200 OK
181
+ #
182
+ # @param [Symbol] verb
183
+ # the lowercase symbol of the HTTP verb (e.g. :get, :delete)
184
+ # @param [String] path
185
+ # the absolute or relative path from {Defaults.endpoint} to make the
186
+ # request against
187
+ # @param [#read, Hash, nil] data
188
+ # the data to use (varies based on the +verb+)
189
+ # @param [Hash] params
190
+ # the params to use for :patch, :post, :put
191
+ # @param [Hash] request_options
192
+ # the list of options/configurables for the actual request
193
+ #
194
+ # @option request_options [true, false] :sign (default: +true+)
195
+ # whether to sign the request using mixlib authentication headers
196
+ #
197
+ # @return [String, Hash]
198
+ # the response body
199
+ #
200
+ def request(verb, path, data = {}, params = {}, request_options = {})
201
+ log.info "#{verb.to_s.upcase} #{path}..."
202
+ log.debug "Chef flavor: #{flavor.inspect}"
203
+
204
+ # Build the URI and request object from the given information
205
+ if [:delete, :get].include?(verb)
206
+ uri = build_uri(verb, path, data)
207
+ else
208
+ uri = build_uri(verb, path, params)
209
+ end
210
+ request = class_for_request(verb).new(uri.request_uri)
211
+
212
+ # Add request headers
213
+ add_request_headers(request)
214
+
215
+ # Setup PATCH/POST/PUT
216
+ if [:patch, :post, :put].include?(verb)
217
+ if data.respond_to?(:read)
218
+ log.info "Detected file/io presence"
219
+ request.body_stream = data
220
+ elsif data.is_a?(Hash)
221
+ # If any of the values in the hash are File-like, assume this is a
222
+ # multi-part post
223
+ if data.values.any? { |value| value.respond_to?(:read) }
224
+ log.info "Detected multipart body"
225
+
226
+ multipart = Multipart::Body.new(data)
227
+
228
+ log.debug "Content-Type: #{multipart.content_type}"
229
+ log.debug "Content-Length: #{multipart.content_length}"
230
+
231
+ request.content_length = multipart.content_length
232
+ request.content_type = multipart.content_type
233
+
234
+ request.body_stream = multipart.stream
235
+ else
236
+ log.info "Detected form data"
237
+ request.form_data = data
238
+ end
239
+ else
240
+ log.info "Detected regular body"
241
+ request.body = data
242
+ end
243
+ end
244
+
245
+ # Sign the request
246
+ if request_options[:sign] == false
247
+ log.info "Skipping signed header authentication (user requested)..."
248
+ else
249
+ add_signing_headers(verb, uri.path, request)
250
+ end
251
+
252
+ # Create the HTTP connection object - since the proxy information defaults
253
+ # to +nil+, we can just pass it to the initializer method instead of doing
254
+ # crazy strange conditionals.
255
+ connection = Net::HTTP.new(uri.host, uri.port,
256
+ proxy_address, proxy_port, proxy_username, proxy_password)
257
+
258
+ # Large or filtered result sets can take a long time to return, so allow
259
+ # setting a longer read_timeout for our client if we want to make an
260
+ # expensive request.
261
+ connection.read_timeout = read_timeout if read_timeout
262
+
263
+ # Apply SSL, if applicable
264
+ if uri.scheme == 'https'
265
+ # Turn on SSL
266
+ connection.use_ssl = true
267
+
268
+ # Custom pem files, no problem!
269
+ if ssl_pem_file
270
+ pem = File.read(ssl_pem_file)
271
+ connection.cert = OpenSSL::X509::Certificate.new(pem)
272
+ connection.key = OpenSSL::PKey::RSA.new(pem)
273
+ connection.verify_mode = OpenSSL::SSL::VERIFY_PEER
274
+ end
275
+
276
+ # Naughty, naughty, naughty! Don't blame when when someone hops in
277
+ # and executes a MITM attack!
278
+ unless ssl_verify
279
+ log.warn "Disabling SSL verification..."
280
+ log.warn "Neither ChefAPI nor the maintainers are responsible for " \
281
+ "damages incurred as a result of disabling SSL verification. " \
282
+ "Please use this with extreme caution, or consider specifying " \
283
+ "a custom certificate using `config.ssl_pem_file'."
284
+ connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
285
+ end
286
+ end
287
+
288
+ # Create a connection using the block form, which will ensure the socket
289
+ # is properly closed in the event of an error.
290
+ connection.start do |http|
291
+ response = http.request(request)
292
+
293
+ log.debug "Raw response:"
294
+ log.debug response.body
295
+
296
+ case response
297
+ when Net::HTTPRedirection
298
+ redirect = URI.parse(response['location']).to_s
299
+ log.debug "Performing HTTP redirect to #{redirect}"
300
+ request(verb, redirect, data)
301
+ when Net::HTTPSuccess
302
+ success(response)
303
+ else
304
+ error(response)
305
+ end
306
+ end
307
+ rescue SocketError, Errno::ECONNREFUSED, EOFError
308
+ log.warn "Unable to reach the Chef Server"
309
+ raise Error::HTTPServerUnavailable.new
310
+ end
311
+
312
+ #
313
+ # Construct a URL from the given verb and path. If the request is a GET or
314
+ # DELETE request, the params are assumed to be query params are are
315
+ # converted as such using {Connection#to_query_string}.
316
+ #
317
+ # If the path is relative, it is merged with the {Defaults.endpoint}
318
+ # attribute. If the path is absolute, it is converted to a URI object and
319
+ # returned.
320
+ #
321
+ # @param [Symbol] verb
322
+ # the lowercase HTTP verb (e.g. :+get+)
323
+ # @param [String] path
324
+ # the absolute or relative HTTP path (url) to get
325
+ # @param [Hash] params
326
+ # the list of params to build the URI with (for GET and DELETE requests)
327
+ #
328
+ # @return [URI]
329
+ #
330
+ def build_uri(verb, path, params = {})
331
+ log.info "Building URI..."
332
+
333
+ # Add any query string parameters
334
+ if querystring = to_query_string(params)
335
+ log.debug "Detected verb deserves a querystring"
336
+ log.debug "Building querystring using #{params.inspect}"
337
+ log.debug "Compiled querystring is #{querystring.inspect}"
338
+ path = [path, querystring].compact.join('?')
339
+ end
340
+
341
+ # Parse the URI
342
+ uri = URI.parse(path)
343
+
344
+ # Don't merge absolute URLs
345
+ unless uri.absolute?
346
+ log.debug "Detected URI is relative"
347
+ log.debug "Appending #{path} to #{endpoint}"
348
+ uri = URI.parse(File.join(endpoint, path))
349
+ end
350
+
351
+ # Return the URI object
352
+ uri
353
+ end
354
+
355
+ #
356
+ # Helper method to get the corresponding +Net::HTTP+ class from the given
357
+ # HTTP verb.
358
+ #
359
+ # @param [#to_s] verb
360
+ # the HTTP verb to create a class from
361
+ #
362
+ # @return [Class]
363
+ #
364
+ def class_for_request(verb)
365
+ Net::HTTP.const_get(verb.to_s.capitalize)
366
+ end
367
+
368
+ #
369
+ # Convert the given hash to a list of query string parameters. Each key and
370
+ # value in the hash is URI-escaped for safety.
371
+ #
372
+ # @param [Hash] hash
373
+ # the hash to create the query string from
374
+ #
375
+ # @return [String, nil]
376
+ # the query string as a string, or +nil+ if there are no params
377
+ #
378
+ def to_query_string(hash)
379
+ hash.map do |key, value|
380
+ "#{URI.escape(key.to_s)}=#{URI.escape(value.to_s)}"
381
+ end.join('&')[/.+/]
382
+ end
383
+
384
+ private
385
+
386
+ #
387
+ # Parse the response object and manipulate the result based on the given
388
+ # +Content-Type+ header. For now, this method only parses JSON, but it
389
+ # could be expanded in the future to accept other content types.
390
+ #
391
+ # @param [HTTP::Message] response
392
+ # the response object from the request
393
+ #
394
+ # @return [String, Hash]
395
+ # the parsed response, as an object
396
+ #
397
+ def success(response)
398
+ log.info "Parsing response as success..."
399
+
400
+ case response['Content-Type']
401
+ when /json/
402
+ log.debug "Detected response as JSON"
403
+ log.debug "Parsing response body as JSON"
404
+ JSON.parse(response.body)
405
+ else
406
+ log.debug "Detected response as text/plain"
407
+ response.body
408
+ end
409
+ end
410
+
411
+ #
412
+ # Raise a response error, extracting as much information from the server's
413
+ # response as possible.
414
+ #
415
+ # @param [HTTP::Message] response
416
+ # the response object from the request
417
+ #
418
+ def error(response)
419
+ log.info "Parsing response as error..."
420
+
421
+ case response['Content-Type']
422
+ when /json/
423
+ log.debug "Detected error response as JSON"
424
+ log.debug "Parsing error response as JSON"
425
+ message = JSON.parse(response.body)
426
+ else
427
+ log.debug "Detected response as text/plain"
428
+ message = response.body
429
+ end
430
+
431
+ case response.code.to_i
432
+ when 400
433
+ raise Error::HTTPBadRequest.new(message: message)
434
+ when 401
435
+ raise Error::HTTPUnauthorizedRequest.new(message: message)
436
+ when 403
437
+ raise Error::HTTPForbiddenRequest.new(message: message)
438
+ when 404
439
+ raise Error::HTTPNotFound.new(message: message)
440
+ when 405
441
+ raise Error::HTTPMethodNotAllowed.new(message: message)
442
+ when 406
443
+ raise Error::HTTPNotAcceptable.new(message: message)
444
+ when 504
445
+ raise Error::HTTPGatewayTimeout.new(message: message)
446
+ when 500..600
447
+ raise Error::HTTPServerUnavailable.new
448
+ else
449
+ raise "I got an error #{response.code} that I don't know how to handle!"
450
+ end
451
+ end
452
+
453
+ #
454
+ # Adds the default headers to the request object.
455
+ #
456
+ # @param [Net::HTTP::Request] request
457
+ #
458
+ def add_request_headers(request)
459
+ log.info "Adding request headers..."
460
+
461
+ headers = {
462
+ 'Accept' => 'application/json',
463
+ 'Content-Type' => 'application/json',
464
+ 'Connection' => 'keep-alive',
465
+ 'Keep-Alive' => '30',
466
+ 'User-Agent' => user_agent,
467
+ 'X-Chef-Version' => '11.4.0',
468
+ }
469
+
470
+ headers.each do |key, value|
471
+ log.debug "#{key}: #{value}"
472
+ request[key] = value
473
+ end
474
+ end
475
+
476
+ #
477
+ # Create a signed header authentication that can be consumed by
478
+ # +Mixlib::Authentication+.
479
+ #
480
+ # @param [Symbol] verb
481
+ # the HTTP verb (e.g. +:get+)
482
+ # @param [String] path
483
+ # the requested URI path (e.g. +/resources/foo+)
484
+ # @param [Net::HTTP::Request] request
485
+ #
486
+ def add_signing_headers(verb, path, request)
487
+ log.info "Adding signed header authentication..."
488
+
489
+ authentication = Authentication.from_options(
490
+ user: client,
491
+ key: key,
492
+ verb: verb,
493
+ path: path,
494
+ body: request.body || request.body_stream,
495
+ )
496
+
497
+ authentication.headers.each do |key, value|
498
+ log.debug "#{key}: #{value}"
499
+ request[key] = value
500
+ end
501
+
502
+ if request.body_stream
503
+ request.body_stream.rewind
504
+ end
505
+ end
506
+ end
507
+ end