chef-api 0.2.0

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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.travis.yml +14 -0
  4. data/Gemfile +12 -0
  5. data/LICENSE +201 -0
  6. data/README.md +264 -0
  7. data/Rakefile +1 -0
  8. data/chef-api.gemspec +25 -0
  9. data/lib/chef-api/boolean.rb +6 -0
  10. data/lib/chef-api/configurable.rb +78 -0
  11. data/lib/chef-api/connection.rb +466 -0
  12. data/lib/chef-api/defaults.rb +130 -0
  13. data/lib/chef-api/error_collection.rb +44 -0
  14. data/lib/chef-api/errors.rb +35 -0
  15. data/lib/chef-api/logger.rb +160 -0
  16. data/lib/chef-api/proxy.rb +72 -0
  17. data/lib/chef-api/resource.rb +16 -0
  18. data/lib/chef-api/resources/base.rb +951 -0
  19. data/lib/chef-api/resources/client.rb +85 -0
  20. data/lib/chef-api/resources/collection_proxy.rb +217 -0
  21. data/lib/chef-api/resources/cookbook.rb +24 -0
  22. data/lib/chef-api/resources/cookbook_version.rb +23 -0
  23. data/lib/chef-api/resources/data_bag.rb +136 -0
  24. data/lib/chef-api/resources/data_bag_item.rb +35 -0
  25. data/lib/chef-api/resources/environment.rb +16 -0
  26. data/lib/chef-api/resources/node.rb +17 -0
  27. data/lib/chef-api/resources/principal.rb +11 -0
  28. data/lib/chef-api/resources/role.rb +16 -0
  29. data/lib/chef-api/resources/user.rb +11 -0
  30. data/lib/chef-api/schema.rb +112 -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/lib/chef-api.rb +76 -0
  38. data/locales/en.yml +89 -0
  39. data/spec/integration/resources/client_spec.rb +8 -0
  40. data/spec/integration/resources/environment_spec.rb +8 -0
  41. data/spec/integration/resources/node_spec.rb +8 -0
  42. data/spec/integration/resources/role_spec.rb +8 -0
  43. data/spec/spec_helper.rb +26 -0
  44. data/spec/support/chef_server.rb +115 -0
  45. data/spec/support/shared/chef_api_resource.rb +91 -0
  46. data/spec/unit/resources/base_spec.rb +47 -0
  47. data/spec/unit/resources/client_spec.rb +69 -0
  48. metadata +128 -0
@@ -0,0 +1,951 @@
1
+ module ChefAPI
2
+ class Resource::Base
3
+ class << self
4
+ # Including the Enumberable module gives us magic
5
+ include Enumerable
6
+
7
+ #
8
+ # Load the given resource from it's on-disk equivalent. This action only
9
+ # makes sense for some resources, and must be defined on a per-resource
10
+ # basis, since the logic varies between resources.
11
+ #
12
+ # @param [String] path
13
+ # the path to the file on disk
14
+ #
15
+ def from_file(path)
16
+ raise Error::AbstractMethod.new(method: 'Resource::Base#from_file')
17
+ end
18
+
19
+ #
20
+ # @todo doc
21
+ #
22
+ def from_url(url, prefix = {})
23
+ from_json(ChefAPI.connection.get(url), prefix)
24
+ end
25
+
26
+ #
27
+ # Get or set the schema for the remote resource. You probably only want
28
+ # to call schema once with a block, because it will overwrite the
29
+ # existing schema (meaning entries are not merged). If a block is given,
30
+ # a new schema object is created, otherwise the current one is returned.
31
+ #
32
+ # @example
33
+ # schema do
34
+ # attribute :id, primary: true
35
+ # attribute :name, type: String, default: 'bacon'
36
+ # attribute :admin, type: Boolean, required: true
37
+ # end
38
+ #
39
+ # @return [Schema]
40
+ # the schema object for this resource
41
+ #
42
+ def schema(&block)
43
+ if block
44
+ @schema = Schema.new(&block)
45
+ else
46
+ @schema
47
+ end
48
+ end
49
+
50
+ #
51
+ # Protect one or more resources from being altered by the user. This is
52
+ # useful if there's an admin client or magical cookbook that you don't
53
+ # want users to modify.
54
+ #
55
+ # @example
56
+ # protect 'chef-webui', 'my-magical-validator'
57
+ #
58
+ # @example
59
+ # protect ->(resource) { resource.name =~ /internal_(.+)/ }
60
+ #
61
+ # @param [Array<String, Proc>] ids
62
+ # the list of "things" to protect
63
+ #
64
+ def protect(*ids)
65
+ ids.each { |id| protected_resources << id }
66
+ end
67
+
68
+ #
69
+ # Create a nested relationship collection. The associated collection
70
+ # is cached on the class, reducing API requests.
71
+ #
72
+ # @example Create an association to environments
73
+ #
74
+ # has_many :environments
75
+ #
76
+ # @example Create an association with custom configuration
77
+ #
78
+ # has_many :environments, class_name: 'Environment'
79
+ #
80
+ def has_many(method, options = {})
81
+ class_name = options[:class_name] || Util.camelize(method).sub(/s$/, '')
82
+ rest_endpoint = options[:rest_endpoint] || method
83
+
84
+ class_eval <<-EOH, __FILE__, __LINE__ + 1
85
+ def #{method}
86
+ associations[:#{method}] ||=
87
+ CollectionProxy.new(self, #{class_name}, '#{rest_endpoint}')
88
+ end
89
+ EOH
90
+ end
91
+
92
+ #
93
+ # @todo doc
94
+ #
95
+ def protected_resources
96
+ @protected_resources ||= []
97
+ end
98
+
99
+ #
100
+ # Get or set the name of the remote resource collection. This is most
101
+ # likely the remote API endpoint (such as +/clients+), without the
102
+ # leading slash.
103
+ #
104
+ # @example Setting a base collection path
105
+ # collection_path '/clients'
106
+ #
107
+ # @example Setting a collection path with nesting
108
+ # collection_path '/data/:name'
109
+ #
110
+ # @param [Symbol] value
111
+ # the value to use for the collection name.
112
+ #
113
+ # @return [Symbol, String]
114
+ # the name of the collection
115
+ #
116
+ def collection_path(value = UNSET)
117
+ if value != UNSET
118
+ @collection_path = value.to_s
119
+ else
120
+ @collection_path ||
121
+ raise(ArgumentError, "collection_path not set for #{self.class}")
122
+ end
123
+ end
124
+
125
+ #
126
+ # Make an authenticated HTTP POST request using the connection object.
127
+ # This method returns a new object representing the response from the
128
+ # server, which should be merged with an existing object's attributes to
129
+ # reflect the newest state of the resource.
130
+ #
131
+ # @param [Hash] body
132
+ # the request body to create the resource with (probably JSON)
133
+ # @param [Hash] prefix
134
+ # the list of prefix options (for nested resources)
135
+ #
136
+ # @return [String]
137
+ # the JSON response from the server
138
+ #
139
+ def post(body, prefix = {})
140
+ path = expanded_collection_path(prefix)
141
+ ChefAPI.connection.post(path, body)
142
+ end
143
+
144
+ #
145
+ # Perform a PUT request to the Chef Server against the given resource or
146
+ # resource identifier. The resource will be partially updated (this
147
+ # method doubles as PATCH) with the given parameters.
148
+ #
149
+ # @param [String, Resource::Base] id
150
+ # a resource object or a string representing the unique identifier of
151
+ # the resource object to update
152
+ # @param [Hash] body
153
+ # the request body to create the resource with (probably JSON)
154
+ # @param [Hash] prefix
155
+ # the list of prefix options (for nested resources)
156
+ #
157
+ # @return [String]
158
+ # the JSON response from the server
159
+ #
160
+ def put(id, body, prefix = {})
161
+ path = resource_path(id, prefix)
162
+ ChefAPI.connection.put(path, body)
163
+ end
164
+
165
+ #
166
+ # Delete the remote resource from the Chef Sserver.
167
+ #
168
+ # @param [String, Fixnum] id
169
+ # the id of the resource to delete
170
+ # @param [Hash] prefix
171
+ # the list of prefix options (for nested resources)
172
+ # @return [true]
173
+ #
174
+ def delete(id, prefix = {})
175
+ path = resource_path(id, prefix)
176
+ ChefAPI.connection.delete(path)
177
+ true
178
+ rescue Error::HTTPNotFound
179
+ true
180
+ end
181
+
182
+ #
183
+ # Get the "list" of items in this resource. This list contains the
184
+ # primary keys of all of the resources in this collection. This method
185
+ # is useful in CLI applications, because it only makes a single API
186
+ # request to gather this data.
187
+ #
188
+ # @param [Hash] prefix
189
+ # the listof prefix options (for nested resources)
190
+ #
191
+ # @example Get the list of all clients
192
+ # Client.list #=> ['validator', 'chef-webui']
193
+ #
194
+ # @return [Array<String>]
195
+ #
196
+ def list(prefix = {})
197
+ path = expanded_collection_path(prefix)
198
+ ChefAPI.connection.get(path).keys.sort
199
+ end
200
+
201
+ #
202
+ # Destroy a record with the given id.
203
+ #
204
+ # @param [String, Fixnum] id
205
+ # the id of the resource to delete
206
+ # @param [Hash] prefix
207
+ # the list of prefix options (for nested resources)
208
+ #
209
+ # @return [Base, nil]
210
+ # the destroyed resource, or nil if the resource does not exist on the
211
+ # remote Chef Server
212
+ #
213
+ def destroy(id, prefix = {})
214
+ resource = fetch(id, prefix)
215
+ return nil if resource.nil?
216
+
217
+ resource.destroy
218
+ resource
219
+ end
220
+
221
+ #
222
+ # Delete all remote resources of the given type from the Chef Server
223
+ #
224
+ # @param [Hash] prefix
225
+ # the list of prefix options (for nested resources)
226
+ # @return [Array<Base>]
227
+ # an array containing the list of resources that were deleted
228
+ #
229
+ def destroy_all(prefix = {})
230
+ map { |resource| resource.destroy }
231
+ end
232
+
233
+ #
234
+ # Fetch a single resource in the remote collection.
235
+ #
236
+ # @example fetch a single client
237
+ # Client.fetch('chef-webui') #=> #<Client name: 'chef-webui', ...>
238
+ #
239
+ # @param [String, Fixnum] id
240
+ # the id of the resource to fetch
241
+ # @param [Hash] prefix
242
+ # the list of prefix options (for nested resources)
243
+ #
244
+ # @return [Resource::Base, nil]
245
+ # an instance of the resource, or nil if that given resource does not
246
+ # exist
247
+ #
248
+ def fetch(id, prefix = {})
249
+ return nil if id.nil?
250
+
251
+ path = resource_path(id, prefix)
252
+ response = ChefAPI.connection.get(path)
253
+ from_json(response, prefix)
254
+ rescue Error::HTTPNotFound
255
+ nil
256
+ end
257
+
258
+ #
259
+ # Build a new resource from the given attributes.
260
+ #
261
+ # @see ChefAPI::Resource::Base#initialize for more information
262
+ #
263
+ # @example build an empty resource
264
+ # Bacon.build #=> #<ChefAPI::Resource::Bacon>
265
+ #
266
+ # @example build a resource with attributes
267
+ # Bacon.build(foo: 'bar') #=> #<ChefAPI::Resource::Baocn foo: bar>
268
+ #
269
+ # @param [Hash] attributes
270
+ # the list of attributes for the new resource - any attributes that
271
+ # are not defined in the schema are silently ignored
272
+ #
273
+ def build(attributes = {}, prefix = {})
274
+ new(attributes, prefix)
275
+ end
276
+
277
+ #
278
+ # Create a new resource and save it to the Chef Server, raising any
279
+ # exceptions that might occur. This method will save the resource back to
280
+ # the Chef Server, raising any validation errors that occur.
281
+ #
282
+ # @raise [Error::ResourceAlreadyExists]
283
+ # if the resource with the primary key already exists on the Chef Server
284
+ # @raise [Error::InvalidResource]
285
+ # if any of the resource's validations fail
286
+ #
287
+ # @param [Hash] attributes
288
+ # the list of attributes to set on the new resource
289
+ #
290
+ # @return [Resource::Base]
291
+ # an instance of the created resource
292
+ #
293
+ def create(attributes = {}, prefix = {})
294
+ resource = build(attributes, prefix)
295
+
296
+ unless resource.new_resource?
297
+ raise Error::ResourceAlreadyExists.new
298
+ end
299
+
300
+ resource.save!
301
+ resource
302
+ end
303
+
304
+ #
305
+ # Check if the given resource exists on the Chef Server.
306
+ #
307
+ # @param [String, Fixnum] id
308
+ # the id of the resource to fetch
309
+ # @param [Hash] prefix
310
+ # the list of prefix options (for nested resources)
311
+ #
312
+ # @return [Boolean]
313
+ #
314
+ def exists?(id, prefix = {})
315
+ !fetch(id, prefix).nil?
316
+ end
317
+
318
+ #
319
+ # Perform a PUT request to the Chef Server for the current resource,
320
+ # updating the given parameters. The parameters may be a full or
321
+ # partial resource update, as supported by the Chef Server.
322
+ #
323
+ # @raise [Error::ResourceNotFound]
324
+ # if the given resource does not exist on the Chef Server
325
+ #
326
+ # @param [String, Fixnum] id
327
+ # the id of the resource to update
328
+ # @param [Hash] attributes
329
+ # the list of attributes to set on the new resource
330
+ # @param [Hash] prefix
331
+ # the list of prefix options (for nested resources)
332
+ #
333
+ # @return [Resource::Base]
334
+ # the new resource
335
+ #
336
+ def update(id, attributes = {}, prefix = {})
337
+ resource = fetch(id, prefix)
338
+
339
+ unless resource
340
+ raise Error::ResourceNotFound.new(type: type, id: id)
341
+ end
342
+
343
+ resource.update(attributes).save
344
+ resource
345
+ end
346
+
347
+ #
348
+ # (Lazy) iterate over each item in the collection, yielding the fully-
349
+ # built resource object. This method, coupled with the Enumerable
350
+ # module, provides us with other methods like +first+ and +map+.
351
+ #
352
+ # @example get the first resource
353
+ # Bacon.first #=> #<ChefAPI::Resource::Bacon>
354
+ #
355
+ # @example get the first 3 resources
356
+ # Bacon.first(3) #=> [#<ChefAPI::Resource::Bacon>, ...]
357
+ #
358
+ # @example iterate over each resource
359
+ # Bacon.each { |bacon| puts bacon.name }
360
+ #
361
+ # @example get all the resource's names
362
+ # Bacon.map(&:name) #=> ["ham", "sausage", "turkey"]
363
+ #
364
+ def each(prefix = {}, &block)
365
+ collection(prefix).each do |resource, path|
366
+ response = ChefAPI.connection.get(path)
367
+ result = from_json(response, prefix)
368
+
369
+ block.call(result) if block
370
+ end
371
+ end
372
+
373
+ #
374
+ # The total number of reosurces in the collection.
375
+ #
376
+ # @return [Fixnum]
377
+ #
378
+ def count(prefix = {})
379
+ collection(prefix).size
380
+ end
381
+ alias_method :size, :count
382
+
383
+ #
384
+ # Return an array of all resources in the collection.
385
+ #
386
+ # @warn
387
+ # Unless you need the _entire_ collection, please consider using the
388
+ # {size} and {each} methods instead as they are much more perforant.
389
+ #
390
+ # @return [Array<Resource::Base>]
391
+ #
392
+ def all
393
+ entries
394
+ end
395
+
396
+ #
397
+ # Construct the object from a JSON response. This method actually just
398
+ # delegates to the +new+ method, but it removes some marshall data and
399
+ # whatnot from the response first.
400
+ #
401
+ # @param [String] response
402
+ # the JSON response from the Chef Server
403
+ #
404
+ # @return [Resource::Base]
405
+ # an instance of the resource represented by this JSON
406
+ #
407
+ def from_json(response, prefix = {})
408
+ response.delete('json_class')
409
+ response.delete('chef_type')
410
+
411
+ new(response, prefix)
412
+ end
413
+
414
+ #
415
+ # The string representation of this class.
416
+ #
417
+ # @example for the Bacon class
418
+ # Bacon.to_s #=> "Resource::Bacon"
419
+ #
420
+ # @return [String]
421
+ #
422
+ def to_s
423
+ classname
424
+ end
425
+
426
+ #
427
+ # The detailed string representation of this class, including the full
428
+ # schema definition.
429
+ #
430
+ # @example for the Bacon class
431
+ # Bacon.inspect #=> "Resource::Bacon(id, description, ...)"
432
+ #
433
+ # @return [String]
434
+ #
435
+ def inspect
436
+ "#{classname}(#{schema.attributes.keys.join(', ')})"
437
+ end
438
+
439
+ #
440
+ # The name for this resource, minus the parent module.
441
+ #
442
+ # @example
443
+ # classname #=> Resource::Bacon
444
+ #
445
+ # @return [String]
446
+ #
447
+ def classname
448
+ name.split('::')[1..-1].join('::')
449
+ end
450
+
451
+ #
452
+ # The type of this resource.
453
+ #
454
+ # @example
455
+ # bacon
456
+ #
457
+ # @return [String]
458
+ #
459
+ def type
460
+ Util.underscore(name.split('::').last).gsub('_', ' ')
461
+ end
462
+
463
+ #
464
+ # The full collection list.
465
+ #
466
+ # @param [Hash] prefix
467
+ # any prefix options to use
468
+ #
469
+ # @return [Array<Resource::Base>]
470
+ # a list of resources in the collection
471
+ #
472
+ def collection(prefix = {})
473
+ ChefAPI.connection.get(expanded_collection_path(prefix))
474
+ end
475
+
476
+ #
477
+ # The path to an individual resource.
478
+ #
479
+ # @param [Hash] prefix
480
+ # the list of prefix options
481
+ #
482
+ # @return [String]
483
+ # the path to the resource
484
+ #
485
+ def resource_path(id, prefix = {})
486
+ [expanded_collection_path(prefix), id].join('/')
487
+ end
488
+
489
+ #
490
+ # Expand the collection path, "interpolating" any parameters. This syntax
491
+ # is heavily borrowed from Rails and it will make more sense by looking
492
+ # at an example.
493
+ #
494
+ # @example
495
+ # /bacon, {} #=> "foo"
496
+ # /bacon/:type, { type: 'crispy' } #=> "bacon/crispy"
497
+ #
498
+ # @raise [Error::MissingURLParameter]
499
+ # if a required parameter is not given
500
+ #
501
+ # @param [Hash] prefix
502
+ # the list of prefix options
503
+ #
504
+ # @return [String]
505
+ # the "interpolated" URL string
506
+ #
507
+ def expanded_collection_path(prefix = {})
508
+ collection_path.gsub(/:\w+/) do |param|
509
+ key = param.delete(':')
510
+ value = prefix[key] || prefix[key.to_sym]
511
+
512
+ if value.nil?
513
+ raise Error::MissingURLParameter.new(param: key)
514
+ end
515
+
516
+ URI.escape(value)
517
+ end.sub(/^\//, '') # Remove leading slash
518
+ end
519
+ end
520
+
521
+ #
522
+ # The list of associations.
523
+ #
524
+ # @return [Hash]
525
+ #
526
+ attr_reader :associations
527
+
528
+ #
529
+ # Initialize a new resource with the given attributes. These attributes
530
+ # are merged with the default values from the schema. Any attributes
531
+ # that aren't defined in the schema are silently ignored for security
532
+ # purposes.
533
+ #
534
+ # @example create a resource using attributes
535
+ # Bacon.new(foo: 'bar', zip: 'zap') #=> #<ChefAPI::Resource::Bacon>
536
+ #
537
+ # @example using a block
538
+ # Bacon.new do |bacon|
539
+ # bacon.foo = 'bar'
540
+ # bacon.zip = 'zap'
541
+ # end
542
+ #
543
+ # @param [Hash] attributes
544
+ # the list of initial attributes to set on the model
545
+ # @param [Hash] prefix
546
+ # the list of prefix options (for nested resources)
547
+ #
548
+ def initialize(attributes = {}, prefix = {})
549
+ @associations = {}
550
+ @_prefix = prefix
551
+
552
+ # Define a getter and setter method for each attribute in the schema
553
+ _attributes.each do |key, value|
554
+ define_singleton_method(key) { _attributes[key] }
555
+ define_singleton_method("#{key}=") { |value| update_attribute(key, value) }
556
+ end
557
+
558
+ attributes.each do |key, value|
559
+ unless ignore_attribute?(key)
560
+ update_attribute(key, value)
561
+ end
562
+ end
563
+
564
+ yield self if block_given?
565
+ end
566
+
567
+ #
568
+ # The primary key for the resource.
569
+ #
570
+ # @return [Symbol]
571
+ # the primary key for this resource
572
+ #
573
+ def primary_key
574
+ self.class.schema.primary_key
575
+ end
576
+
577
+ #
578
+ # The unique id for this resource.
579
+ #
580
+ # @return [Object]
581
+ #
582
+ def id
583
+ _attributes[primary_key]
584
+ end
585
+
586
+ #
587
+ # @todo doc
588
+ #
589
+ def _prefix
590
+ @_prefix
591
+ end
592
+
593
+ #
594
+ # The list of attributes on this resource.
595
+ #
596
+ # @return [Hash<Symbol, Object>]
597
+ #
598
+ def _attributes
599
+ @_attributes ||= {}.merge(self.class.schema.attributes)
600
+ end
601
+
602
+ #
603
+ # Determine if this resource has the given attribute.
604
+ #
605
+ # @param [Symbol, String] key
606
+ # the attribute key to find
607
+ #
608
+ # @return [Boolean]
609
+ # true if the attribute exists, false otherwise
610
+ #
611
+ def attribute?(key)
612
+ _attributes.has_key?(key.to_sym)
613
+ end
614
+
615
+ #
616
+ # Determine if this current resource is protected. Resources may be
617
+ # protected by name or by a Proc. A protected resource is one that should
618
+ # not be modified (i.e. created/updated/deleted) by the user. An example of
619
+ # a protected resource is the pivotal key or the chef-webui client.
620
+ #
621
+ # @return [Boolean]
622
+ #
623
+ def protected?
624
+ @protected ||= self.class.protected_resources.any? do |thing|
625
+ if thing.is_a?(Proc)
626
+ thing.call(self)
627
+ else
628
+ id == thing
629
+ end
630
+ end
631
+ end
632
+
633
+ #
634
+ # Reload (or reset) this object using the values currently stored on the
635
+ # remote server. This method will also clear any cached collection proxies
636
+ # so they will be reloaded the next time they are requested. If the remote
637
+ # record does not exist, no attributes are modified.
638
+ #
639
+ # @warn
640
+ # This will remove any custom values you have set on the resource!
641
+ #
642
+ # @return [self]
643
+ # the instance of the reloaded record
644
+ #
645
+ def reload!
646
+ associations.clear
647
+
648
+ remote = self.class.fetch(id, _prefix)
649
+ return self if remote.nil?
650
+
651
+ remote._attributes.each do |key, value|
652
+ update_attribute(key, value)
653
+ end
654
+
655
+ self
656
+ end
657
+
658
+ #
659
+ # Commit the resource and any changes to the remote Chef Server. Any errors
660
+ # will raise an exception in the main thread and the resource will not be
661
+ # committed back to the Chef Server.
662
+ #
663
+ # Any response errors (such as server-side responses) that ChefAPI failed
664
+ # to account for in validations will also raise an exception.
665
+ #
666
+ # @return [Boolean]
667
+ # true if the resource was saved
668
+ #
669
+ def save!
670
+ validate!
671
+
672
+ response = if new_resource?
673
+ self.class.post(to_json, _prefix)
674
+ else
675
+ self.class.put(id, to_json, _prefix)
676
+ end
677
+
678
+ # Update our local copy with any partial information that was returned
679
+ # from the server, ignoring an "bad" attributes that aren't defined in
680
+ # our schema.
681
+ response.each do |key, value|
682
+ update_attribute(key, value) if attribute?(key)
683
+ end
684
+
685
+ true
686
+ end
687
+
688
+ #
689
+ # Commit the resource and any changes to the remote Chef Server. Any errors
690
+ # are gracefully handled and added to the resource's error collection for
691
+ # handling.
692
+ #
693
+ # @return [Boolean]
694
+ # true if the save was successfuly, false otherwise
695
+ #
696
+ def save
697
+ save!
698
+ rescue
699
+ false
700
+ end
701
+
702
+ #
703
+ # Remove the resource from the Chef Server.
704
+ #
705
+ # @return [self]
706
+ # the current instance of this object
707
+ #
708
+ def destroy
709
+ self.class.delete(id, _prefix)
710
+ self
711
+ end
712
+
713
+ #
714
+ # Update a subset of attributes on the current resource. This is a handy
715
+ # way to update multiple attributes at once.
716
+ #
717
+ # @param [Hash] attributes
718
+ # the list of attributes to update
719
+ #
720
+ # @return [self]
721
+ #
722
+ def update(attributes = {})
723
+ attributes.each do |key, value|
724
+ update_attribute(key, value)
725
+ end
726
+
727
+ self
728
+ end
729
+
730
+ #
731
+ # Update a single attribute in the attributes hash.
732
+ #
733
+ # @raise
734
+ #
735
+ def update_attribute(key, value)
736
+ unless attribute?(key.to_sym)
737
+ raise Error::UnknownAttribute.new(attribute: key)
738
+ end
739
+
740
+ _attributes[key.to_sym] = value
741
+ end
742
+
743
+ #
744
+ # The list of validators for this resource. This is primarily set and
745
+ # managed by the underlying schema clean room.
746
+ #
747
+ # @return [Array<~Validator::Base>]
748
+ # the list of validators for this resource
749
+ #
750
+ def validators
751
+ @validators ||= self.class.schema.validators
752
+ end
753
+
754
+ #
755
+ # Run all of this resource's validations, raising an exception if any
756
+ # validations fail.
757
+ #
758
+ # @raise [Error::InvalidResource]
759
+ # if any of the validations fail
760
+ #
761
+ # @return [Boolean]
762
+ # true if the validation was successful - this method will never return
763
+ # anything other than true because an exception is raised if validations
764
+ # fail
765
+ #
766
+ def validate!
767
+ unless valid?
768
+ sentence = errors.full_messages.join(', ')
769
+ raise Error::InvalidResource.new(errors: sentence)
770
+ end
771
+
772
+ true
773
+ end
774
+
775
+ #
776
+ # Determine if the current resource is valid. This relies on the
777
+ # validations defined in the schema at initialization.
778
+ #
779
+ # @return [Boolean]
780
+ # true if the resource is valid, false otherwise
781
+ #
782
+ def valid?
783
+ errors.clear
784
+
785
+ validators.each do |validator|
786
+ validator.validate(self)
787
+ end
788
+
789
+ errors.empty?
790
+ end
791
+
792
+ #
793
+ # Check if this resource exists on the remote Chef Server. This is useful
794
+ # when determining if a resource should be saved or updated, since a
795
+ # resource must exist before it can be saved.
796
+ #
797
+ # @example when the resource does not exist on the remote Chef Server
798
+ # bacon = Bacon.new
799
+ # bacon.new_resource? #=> true
800
+ #
801
+ # @example when the resource exists on the remote Chef Server
802
+ # bacon = Bacon.first
803
+ # bacon.new_resource? #=> false
804
+ #
805
+ # @return [Boolean]
806
+ # true if the resource exists on the remote Chef Server, false otherwise
807
+ #
808
+ def new_resource?
809
+ !self.class.exists?(id, _prefix)
810
+ end
811
+
812
+ #
813
+ # Check if the local resource is in sync with the remote Chef Server. When
814
+ # a remote resource is updated, ChefAPI has no way of knowing it's cached
815
+ # resources are dirty unless additional requests are made against the
816
+ # remote Chef Server and diffs are compared.
817
+ #
818
+ # @example when the resource is out of sync with the remote Chef Server
819
+ # bacon = Bacon.first
820
+ # bacon.description = "I'm different, yeah, I'm different!"
821
+ # bacon.dirty? #=> true
822
+ #
823
+ # @example when the resource is in sync with the remote Chef Server
824
+ # bacon = Bacon.first
825
+ # bacon.dirty? #=> false
826
+ #
827
+ # @return [Boolean]
828
+ # true if the local resource has differing attributes from the same
829
+ # resource on the remote Chef Server, false otherwise
830
+ #
831
+ def dirty?
832
+ new_resource? || !diff.empty?
833
+ end
834
+
835
+ #
836
+ # Calculate a differential of the attributes on the local resource with
837
+ # it's remote Chef Server counterpart.
838
+ #
839
+ # @example when the local resource is in sync with the remote resource
840
+ # bacon = Bacon.first
841
+ # bacon.diff #=> {}
842
+ #
843
+ # @example when the local resource differs from the remote resource
844
+ # bacon = Bacon.first
845
+ # bacon.description = "My new description"
846
+ # bacon.diff #=> { :description => { :local => "My new description", :remote => "Old description" } }
847
+ #
848
+ # @warn
849
+ # This is a VERY expensive operation - use it sparringly!
850
+ #
851
+ # @return [Hash]
852
+ #
853
+ def diff
854
+ diff = {}
855
+
856
+ remote = self.class.fetch(id, _prefix) || self.class.new({}, _prefix)
857
+ remote._attributes.each do |key, value|
858
+ unless _attributes[key] == value
859
+ diff[key] = { local: _attributes[key], remote: value }
860
+ end
861
+ end
862
+
863
+ diff
864
+ end
865
+
866
+ #
867
+ # The URL for this resource on the Chef Server.
868
+ #
869
+ # @example Get the resource path for a resource
870
+ # bacon = Bacon.first
871
+ # bacon.resource_path #=> /bacons/crispy
872
+ #
873
+ # @return [String]
874
+ # the partial URL path segment
875
+ #
876
+ def resource_path
877
+ self.class.resource_path(id, _prefix)
878
+ end
879
+
880
+ #
881
+ # Determine if a given attribute should be ignored. Ignored attributes
882
+ # are defined at the schema level and are frozen.
883
+ #
884
+ # @param [Symbol] key
885
+ # the attribute to check ignorance
886
+ #
887
+ # @return [Boolean]
888
+ #
889
+ def ignore_attribute?(key)
890
+ self.class.schema.ignored_attributes.has_key?(key.to_sym)
891
+ end
892
+
893
+ #
894
+ # The collection of errors on the resource.
895
+ #
896
+ # @return [ErrorCollection]
897
+ #
898
+ def errors
899
+ @errors ||= ErrorCollection.new
900
+ end
901
+
902
+ #
903
+ # The hash representation of this resource. All attributes are serialized
904
+ # and any values that respond to +to_hash+ are also serialized.
905
+ #
906
+ # @return [Hash]
907
+ #
908
+ def to_hash
909
+ {}.tap do |hash|
910
+ _attributes.each do |key, value|
911
+ hash[key] = value.respond_to?(:to_hash) ? value.to_hash : value
912
+ end
913
+ end
914
+ end
915
+
916
+ #
917
+ # The JSON serialization of this resource.
918
+ #
919
+ # @return [String]
920
+ #
921
+ def to_json
922
+ JSON.fast_generate(to_hash)
923
+ end
924
+
925
+ #
926
+ # Custom to_s method for easier readability.
927
+ #
928
+ # @return [String]
929
+ #
930
+ def to_s
931
+ "#<#{self.class.classname} #{primary_key}: #{id.inspect}>"
932
+ end
933
+
934
+ #
935
+ # Custom inspect method for easier readability.
936
+ #
937
+ # @return [String]
938
+ #
939
+ def inspect
940
+ attrs = (_prefix).merge(_attributes).map do |key, value|
941
+ if value.is_a?(String)
942
+ "#{key}: #{Util.truncate(value, length: 50).inspect}"
943
+ else
944
+ "#{key}: #{value.inspect}"
945
+ end
946
+ end
947
+
948
+ "#<#{self.class.classname} #{attrs.join(', ')}>"
949
+ end
950
+ end
951
+ end