chef-infra-api 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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