module ChefAPI class Resource::Base class << self # Including the Enumberable module gives us magic include Enumerable # # Load the given resource from it's on-disk equivalent. This action only # makes sense for some resources, and must be defined on a per-resource # basis, since the logic varies between resources. # # @param [String] path # the path to the file on disk # def from_file(path) raise Error::AbstractMethod.new(method: 'Resource::Base#from_file') end # # @todo doc # def from_url(url, prefix = {}) from_json(connection.get(url), prefix) end # # Get or set the schema for the remote resource. You probably only want # to call schema once with a block, because it will overwrite the # existing schema (meaning entries are not merged). If a block is given, # a new schema object is created, otherwise the current one is returned. # # @example # schema do # attribute :id, primary: true # attribute :name, type: String, default: 'bacon' # attribute :admin, type: Boolean, required: true # end # # @return [Schema] # the schema object for this resource # def schema(&block) if block @schema = Schema.new(&block) else @schema end end # # Protect one or more resources from being altered by the user. This is # useful if there's an admin client or magical cookbook that you don't # want users to modify. # # @example # protect 'chef-webui', 'my-magical-validator' # # @example # protect ->(resource) { resource.name =~ /internal_(.+)/ } # # @param [Array<String, Proc>] ids # the list of "things" to protect # def protect(*ids) ids.each { |id| protected_resources << id } end # # Create a nested relationship collection. The associated collection # is cached on the class, reducing API requests. # # @example Create an association to environments # # has_many :environments # # @example Create an association with custom configuration # # has_many :environments, class_name: 'Environment' # def has_many(method, options = {}) class_name = options[:class_name] || "Resource::#{Util.camelize(method).sub(/s$/, '')}" rest_endpoint = options[:rest_endpoint] || method class_eval <<-EOH, __FILE__, __LINE__ + 1 def #{method} associations[:#{method}] ||= Resource::CollectionProxy.new(self, #{class_name}, '#{rest_endpoint}') end EOH end # # @todo doc # def protected_resources @protected_resources ||= [] end # # Get or set the name of the remote resource collection. This is most # likely the remote API endpoint (such as +/clients+), without the # leading slash. # # @example Setting a base collection path # collection_path '/clients' # # @example Setting a collection path with nesting # collection_path '/data/:name' # # @param [Symbol] value # the value to use for the collection name. # # @return [Symbol, String] # the name of the collection # def collection_path(value = UNSET) if value != UNSET @collection_path = value.to_s else @collection_path || raise(ArgumentError, "collection_path not set for #{self.class}") end end # # Make an authenticated HTTP POST request using the connection object. # This method returns a new object representing the response from the # server, which should be merged with an existing object's attributes to # reflect the newest state of the resource. # # @param [Hash] body # the request body to create the resource with (probably JSON) # @param [Hash] prefix # the list of prefix options (for nested resources) # # @return [String] # the JSON response from the server # def post(body, prefix = {}) path = expanded_collection_path(prefix) connection.post(path, body) end # # Perform a PUT request to the Chef Server against the given resource or # resource identifier. The resource will be partially updated (this # method doubles as PATCH) with the given parameters. # # @param [String, Resource::Base] id # a resource object or a string representing the unique identifier of # the resource object to update # @param [Hash] body # the request body to create the resource with (probably JSON) # @param [Hash] prefix # the list of prefix options (for nested resources) # # @return [String] # the JSON response from the server # def put(id, body, prefix = {}) path = resource_path(id, prefix) connection.put(path, body) end # # Delete the remote resource from the Chef Sserver. # # @param [String, Fixnum] id # the id of the resource to delete # @param [Hash] prefix # the list of prefix options (for nested resources) # @return [true] # def delete(id, prefix = {}) path = resource_path(id, prefix) connection.delete(path) true rescue Error::HTTPNotFound true end # # Get the "list" of items in this resource. This list contains the # primary keys of all of the resources in this collection. This method # is useful in CLI applications, because it only makes a single API # request to gather this data. # # @param [Hash] prefix # the listof prefix options (for nested resources) # # @example Get the list of all clients # Client.list #=> ['validator', 'chef-webui'] # # @return [Array<String>] # def list(prefix = {}) path = expanded_collection_path(prefix) connection.get(path).keys.sort end # # Destroy a record with the given id. # # @param [String, Fixnum] id # the id of the resource to delete # @param [Hash] prefix # the list of prefix options (for nested resources) # # @return [Base, nil] # the destroyed resource, or nil if the resource does not exist on the # remote Chef Server # def destroy(id, prefix = {}) resource = fetch(id, prefix) return nil if resource.nil? resource.destroy resource end # # Delete all remote resources of the given type from the Chef Server # # @param [Hash] prefix # the list of prefix options (for nested resources) # @return [Array<Base>] # an array containing the list of resources that were deleted # def destroy_all(prefix = {}) map { |resource| resource.destroy } end # # Fetch a single resource in the remote collection. # # @example fetch a single client # Client.fetch('chef-webui') #=> #<Client name: 'chef-webui', ...> # # @param [String, Fixnum] id # the id of the resource to fetch # @param [Hash] prefix # the list of prefix options (for nested resources) # # @return [Resource::Base, nil] # an instance of the resource, or nil if that given resource does not # exist # def fetch(id, prefix = {}) return nil if id.nil? path = resource_path(id, prefix) response = connection.get(path) from_json(response, prefix) rescue Error::HTTPNotFound nil end # # Build a new resource from the given attributes. # # @see ChefAPI::Resource::Base#initialize for more information # # @example build an empty resource # Bacon.build #=> #<ChefAPI::Resource::Bacon> # # @example build a resource with attributes # Bacon.build(foo: 'bar') #=> #<ChefAPI::Resource::Baocn foo: bar> # # @param [Hash] attributes # the list of attributes for the new resource - any attributes that # are not defined in the schema are silently ignored # def build(attributes = {}, prefix = {}) new(attributes, prefix) end # # Create a new resource and save it to the Chef Server, raising any # exceptions that might occur. This method will save the resource back to # the Chef Server, raising any validation errors that occur. # # @raise [Error::ResourceAlreadyExists] # if the resource with the primary key already exists on the Chef Server # @raise [Error::InvalidResource] # if any of the resource's validations fail # # @param [Hash] attributes # the list of attributes to set on the new resource # # @return [Resource::Base] # an instance of the created resource # def create(attributes = {}, prefix = {}) resource = build(attributes, prefix) unless resource.new_resource? raise Error::ResourceAlreadyExists.new end resource.save! resource end # # Check if the given resource exists on the Chef Server. # # @param [String, Fixnum] id # the id of the resource to fetch # @param [Hash] prefix # the list of prefix options (for nested resources) # # @return [Boolean] # def exists?(id, prefix = {}) !fetch(id, prefix).nil? end # # Perform a PUT request to the Chef Server for the current resource, # updating the given parameters. The parameters may be a full or # partial resource update, as supported by the Chef Server. # # @raise [Error::ResourceNotFound] # if the given resource does not exist on the Chef Server # # @param [String, Fixnum] id # the id of the resource to update # @param [Hash] attributes # the list of attributes to set on the new resource # @param [Hash] prefix # the list of prefix options (for nested resources) # # @return [Resource::Base] # the new resource # def update(id, attributes = {}, prefix = {}) resource = fetch(id, prefix) unless resource raise Error::ResourceNotFound.new(type: type, id: id) end resource.update(attributes).save resource end # # (Lazy) iterate over each item in the collection, yielding the fully- # built resource object. This method, coupled with the Enumerable # module, provides us with other methods like +first+ and +map+. # # @example get the first resource # Bacon.first #=> #<ChefAPI::Resource::Bacon> # # @example get the first 3 resources # Bacon.first(3) #=> [#<ChefAPI::Resource::Bacon>, ...] # # @example iterate over each resource # Bacon.each { |bacon| puts bacon.name } # # @example get all the resource's names # Bacon.map(&:name) #=> ["ham", "sausage", "turkey"] # def each(prefix = {}, &block) collection(prefix).each do |resource, path| response = connection.get(path) result = from_json(response, prefix) block.call(result) if block end end # # The total number of reosurces in the collection. # # @return [Fixnum] # def count(prefix = {}) collection(prefix).size end alias_method :size, :count # # Return an array of all resources in the collection. # # @note Unless you need the _entire_ collection, please consider using the # {size} and {each} methods instead as they are much more perforant. # # @return [Array<Resource::Base>] # def all entries end # # Construct the object from a JSON response. This method actually just # delegates to the +new+ method, but it removes some marshall data and # whatnot from the response first. # # @param [String] response # the JSON response from the Chef Server # # @return [Resource::Base] # an instance of the resource represented by this JSON # def from_json(response, prefix = {}) response.delete('json_class') response.delete('chef_type') new(response, prefix) end # # The string representation of this class. # # @example for the Bacon class # Bacon.to_s #=> "Resource::Bacon" # # @return [String] # def to_s classname end # # The detailed string representation of this class, including the full # schema definition. # # @example for the Bacon class # Bacon.inspect #=> "Resource::Bacon(id, description, ...)" # # @return [String] # def inspect "#{classname}(#{schema.attributes.keys.join(', ')})" end # # The name for this resource, minus the parent module. # # @example # classname #=> Resource::Bacon # # @return [String] # def classname name.split('::')[1..-1].join('::') end # # The type of this resource. # # @example # bacon # # @return [String] # def type Util.underscore(name.split('::').last).gsub('_', ' ') end # # The full collection list. # # @param [Hash] prefix # any prefix options to use # # @return [Array<Resource::Base>] # a list of resources in the collection # def collection(prefix = {}) connection.get(expanded_collection_path(prefix)) end # # The path to an individual resource. # # @param [Hash] prefix # the list of prefix options # # @return [String] # the path to the resource # def resource_path(id, prefix = {}) [expanded_collection_path(prefix), id].join('/') end # # Expand the collection path, "interpolating" any parameters. This syntax # is heavily borrowed from Rails and it will make more sense by looking # at an example. # # @example # /bacon, {} #=> "foo" # /bacon/:type, { type: 'crispy' } #=> "bacon/crispy" # # @raise [Error::MissingURLParameter] # if a required parameter is not given # # @param [Hash] prefix # the list of prefix options # # @return [String] # the "interpolated" URL string # def expanded_collection_path(prefix = {}) collection_path.gsub(/:\w+/) do |param| key = param.delete(':') value = prefix[key] || prefix[key.to_sym] if value.nil? raise Error::MissingURLParameter.new(param: key) end URI.escape(value) end.sub(/^\//, '') # Remove leading slash end # # The current connection object. # # @return [ChefAPI::Connection] # def connection Thread.current['chefapi.connection'] || ChefAPI.connection end end # # The list of associations. # # @return [Hash] # attr_reader :associations # # Initialize a new resource with the given attributes. These attributes # are merged with the default values from the schema. Any attributes # that aren't defined in the schema are silently ignored for security # purposes. # # @example create a resource using attributes # Bacon.new(foo: 'bar', zip: 'zap') #=> #<ChefAPI::Resource::Bacon> # # @example using a block # Bacon.new do |bacon| # bacon.foo = 'bar' # bacon.zip = 'zap' # end # # @param [Hash] attributes # the list of initial attributes to set on the model # @param [Hash] prefix # the list of prefix options (for nested resources) # def initialize(attributes = {}, prefix = {}) @schema = self.class.schema.dup @schema.load_flavor(self.class.connection.flavor) @associations = {} @_prefix = prefix # Define a getter and setter method for each attribute in the schema _attributes.each do |key, value| define_singleton_method(key) { _attributes[key] } define_singleton_method("#{key}=") { |value| update_attribute(key, value) } end attributes.each do |key, value| unless ignore_attribute?(key) update_attribute(key, value) end end yield self if block_given? end # # The primary key for the resource. # # @return [Symbol] # the primary key for this resource # def primary_key @schema.primary_key end # # The unique id for this resource. # # @return [Object] # def id _attributes[primary_key] end # # @todo doc # def _prefix @_prefix end # # The list of attributes on this resource. # # @return [Hash<Symbol, Object>] # def _attributes @_attributes ||= {}.merge(@schema.attributes) end # # Determine if this resource has the given attribute. # # @param [Symbol, String] key # the attribute key to find # # @return [Boolean] # true if the attribute exists, false otherwise # def attribute?(key) _attributes.has_key?(key.to_sym) end # # Determine if this current resource is protected. Resources may be # protected by name or by a Proc. A protected resource is one that should # not be modified (i.e. created/updated/deleted) by the user. An example of # a protected resource is the pivotal key or the chef-webui client. # # @return [Boolean] # def protected? @protected ||= self.class.protected_resources.any? do |thing| if thing.is_a?(Proc) thing.call(self) else id == thing end end end # # Reload (or reset) this object using the values currently stored on the # remote server. This method will also clear any cached collection proxies # so they will be reloaded the next time they are requested. If the remote # record does not exist, no attributes are modified. # # @note This will remove any custom values you have set on the resource! # # @return [self] # the instance of the reloaded record # def reload! associations.clear remote = self.class.fetch(id, _prefix) return self if remote.nil? remote._attributes.each do |key, value| update_attribute(key, value) end self end # # Commit the resource and any changes to the remote Chef Server. Any errors # will raise an exception in the main thread and the resource will not be # committed back to the Chef Server. # # Any response errors (such as server-side responses) that ChefAPI failed # to account for in validations will also raise an exception. # # @return [Boolean] # true if the resource was saved # def save! validate! response = if new_resource? self.class.post(to_json, _prefix) else self.class.put(id, to_json, _prefix) end # Update our local copy with any partial information that was returned # from the server, ignoring an "bad" attributes that aren't defined in # our schema. response.each do |key, value| update_attribute(key, value) if attribute?(key) end true end # # Commit the resource and any changes to the remote Chef Server. Any errors # are gracefully handled and added to the resource's error collection for # handling. # # @return [Boolean] # true if the save was successfuly, false otherwise # def save save! rescue false end # # Remove the resource from the Chef Server. # # @return [self] # the current instance of this object # def destroy self.class.delete(id, _prefix) self end # # Update a subset of attributes on the current resource. This is a handy # way to update multiple attributes at once. # # @param [Hash] attributes # the list of attributes to update # # @return [self] # def update(attributes = {}) attributes.each do |key, value| update_attribute(key, value) end self end # # Update a single attribute in the attributes hash. # # @raise # def update_attribute(key, value) unless attribute?(key.to_sym) raise Error::UnknownAttribute.new(attribute: key) end _attributes[key.to_sym] = value end # # The list of validators for this resource. This is primarily set and # managed by the underlying schema clean room. # # @return [Array<~Validator::Base>] # the list of validators for this resource # def validators @validators ||= @schema.validators end # # Run all of this resource's validations, raising an exception if any # validations fail. # # @raise [Error::InvalidResource] # if any of the validations fail # # @return [Boolean] # true if the validation was successful - this method will never return # anything other than true because an exception is raised if validations # fail # def validate! unless valid? sentence = errors.full_messages.join(', ') raise Error::InvalidResource.new(errors: sentence) end true end # # Determine if the current resource is valid. This relies on the # validations defined in the schema at initialization. # # @return [Boolean] # true if the resource is valid, false otherwise # def valid? errors.clear validators.each do |validator| validator.validate(self) end errors.empty? end # # Check if this resource exists on the remote Chef Server. This is useful # when determining if a resource should be saved or updated, since a # resource must exist before it can be saved. # # @example when the resource does not exist on the remote Chef Server # bacon = Bacon.new # bacon.new_resource? #=> true # # @example when the resource exists on the remote Chef Server # bacon = Bacon.first # bacon.new_resource? #=> false # # @return [Boolean] # true if the resource exists on the remote Chef Server, false otherwise # def new_resource? !self.class.exists?(id, _prefix) end # # Check if the local resource is in sync with the remote Chef Server. When # a remote resource is updated, ChefAPI has no way of knowing it's cached # resources are dirty unless additional requests are made against the # remote Chef Server and diffs are compared. # # @example when the resource is out of sync with the remote Chef Server # bacon = Bacon.first # bacon.description = "I'm different, yeah, I'm different!" # bacon.dirty? #=> true # # @example when the resource is in sync with the remote Chef Server # bacon = Bacon.first # bacon.dirty? #=> false # # @return [Boolean] # true if the local resource has differing attributes from the same # resource on the remote Chef Server, false otherwise # def dirty? new_resource? || !diff.empty? end # # Calculate a differential of the attributes on the local resource with # it's remote Chef Server counterpart. # # @example when the local resource is in sync with the remote resource # bacon = Bacon.first # bacon.diff #=> {} # # @example when the local resource differs from the remote resource # bacon = Bacon.first # bacon.description = "My new description" # bacon.diff #=> { :description => { :local => "My new description", :remote => "Old description" } } # # @note This is a VERY expensive operation - use it sparringly! # # @return [Hash] # def diff diff = {} remote = self.class.fetch(id, _prefix) || self.class.new({}, _prefix) remote._attributes.each do |key, value| unless _attributes[key] == value diff[key] = { local: _attributes[key], remote: value } end end diff end # # The URL for this resource on the Chef Server. # # @example Get the resource path for a resource # bacon = Bacon.first # bacon.resource_path #=> /bacons/crispy # # @return [String] # the partial URL path segment # def resource_path self.class.resource_path(id, _prefix) end # # Determine if a given attribute should be ignored. Ignored attributes # are defined at the schema level and are frozen. # # @param [Symbol] key # the attribute to check ignorance # # @return [Boolean] # def ignore_attribute?(key) @schema.ignored_attributes.has_key?(key.to_sym) end # # The collection of errors on the resource. # # @return [ErrorCollection] # def errors @errors ||= ErrorCollection.new end # # The hash representation of this resource. All attributes are serialized # and any values that respond to +to_hash+ are also serialized. # # @return [Hash] # def to_hash {}.tap do |hash| _attributes.each do |key, value| hash[key] = value.respond_to?(:to_hash) ? value.to_hash : value end end end # # The JSON serialization of this resource. # # @return [String] # def to_json(*) JSON.fast_generate(to_hash) end # # Custom to_s method for easier readability. # # @return [String] # def to_s "#<#{self.class.classname} #{primary_key}: #{id.inspect}>" end # # Custom inspect method for easier readability. # # @return [String] # def inspect attrs = (_prefix).merge(_attributes).map do |key, value| if value.is_a?(String) "#{key}: #{Util.truncate(value, length: 50).inspect}" else "#{key}: #{value.inspect}" end end "#<#{self.class.classname} #{attrs.join(', ')}>" end end end