# encoding: UTF-8 module Ridley::Chef class Cookbook require_relative 'cookbook/metadata' require_relative 'cookbook/syntax_check' class << self # @param [String] filepath # a path on disk to the location of a file to checksum # # @return [String] # a checksum that can be used to uniquely identify the file understood # by a Chef Server. def checksum(filepath) Ridley::Chef::Digester.md5_checksum_for_file(filepath) end # Creates a new instance of Ridley::Chef::Cookbook from a path on disk containing # a Cookbook. # # The name of the Cookbook is determined by the value of the name attribute set in # the cookbooks' metadata. If the name attribute is not present the name of the loaded # cookbook is determined by directory containing the cookbook. # # @param [#to_s] path # a path on disk to the location of a Cookbook # # @raise [IOError] if the path does not contain a metadata.rb or metadata.json file # # @return [Ridley::Chef::Cookbook] def from_path(path) path = Pathname.new(path) if (file = path.join(Metadata::COMPILED_FILE_NAME)).exist? metadata = Metadata.from_json(File.read(file)) elsif (file = path.join(Metadata::RAW_FILE_NAME)).exist? metadata = Metadata.from_file(file) else raise IOError, "no #{Metadata::COMPILED_FILE_NAME} or #{Metadata::RAW_FILE_NAME} found at #{path}" end unless metadata.name.presence raise Ridley::Errors::MissingNameAttribute.new(path) end new(metadata.name, path, metadata) end end CHEF_TYPE = "cookbook_version".freeze CHEF_JSON_CLASS = "Chef::CookbookVersion".freeze extend Forwardable attr_reader :cookbook_name attr_reader :path attr_reader :metadata # @return [Hashie::Mash] # a Hashie::Mash containing Cookbook file category names as keys and an Array of Hashes # containing metadata about the files belonging to that category. This is used # to communicate what a Cookbook looks like when uploading to a Chef Server. # # example: # { # :recipes => [ # { # name: "default.rb", # path: "recipes/default.rb", # checksum: "fb1f925dcd5fc4ebf682c4442a21c619", # specificity: "default" # } # ] # ... # ... # } attr_reader :manifest # @return [Boolean] attr_accessor :frozen def_delegator :@metadata, :version def initialize(name, path, metadata) @cookbook_name = name @path = Pathname.new(path) @metadata = metadata @frozen = false @chefignore = Ridley::Chef::Chefignore.new(@path) rescue nil clear_files load_files end # @return [Hash] # an hash containing the checksums and expanded file paths of all of the # files found in the instance of CachedCookbook # # example: # { # "da97c94bb6acb2b7900cbf951654fea3" => "/Users/reset/.ridley/nginx-0.101.2/README.md" # } def checksums {}.tap do |checksums| files.each do |file| checksums[self.class.checksum(file)] = file end end end # Compiles the raw metadata of the cookbook and writes it to a metadata.json file at the given # out path. The default out path is the directory containing the cookbook itself. # # @param [String] out # directory to output compiled metadata to # # @return [String] # path to the compiled metadata def compile_metadata(out = self.path) filepath = File.join(out, Metadata::COMPILED_FILE_NAME) File.open(filepath, "wb+") do |f| f.write(metadata.to_json) end filepath end # Returns true if the cookbook instance has a compiled metadata file and false if it # does not. # # @return [Boolean] def compiled_metadata? manifest[:root_files].any? { |file| file[:name].downcase == Metadata::COMPILED_FILE_NAME } end # @param [Symbol] category # the category of file to generate metadata about # @param [String] target # the filepath to the file to get metadata information about # # @return [Hash] # a Hash containing a name, path, checksum, and specificity key representing the # metadata about a file contained in a Cookbook. This metadata is used when # uploading a Cookbook's files to a Chef Server. # # @example # file_metadata(:root_files, "somefile.h") => { # name: "default.rb", # path: "recipes/default.rb", # checksum: "fb1f925dcd5fc4ebf682c4442a21c619", # specificity: "default" # } def file_metadata(category, target) target = Pathname.new(target) { name: target.basename.to_s, path: target.relative_path_from(path).to_s, checksum: self.class.checksum(target), specificity: file_specificity(category, target) } end # @param [Symbol] category # @param [Pathname] target # # @return [String] def file_specificity(category, target) case category when :files, :templates relpath = target.relative_path_from(path).to_s relpath.slice(/(.+)\/(.+)\/.+/, 2) || 'root_default' else 'default' end end # @return [String] # the name of the cookbook and the version number separated by a dash (-). # # example: # "nginx-0.101.2" def name "#{cookbook_name}-#{version}" end # Reload the cookbook from the files located on disk at `#path`. def reload clear_files load_files end def validate raise IOError, "No Cookbook found at: #{path}" unless path.exist? unless syntax_checker.validate_ruby_files raise Ridley::Errors::CookbookSyntaxError, "Invalid ruby files in cookbook: #{cookbook_name} (#{version})." end unless syntax_checker.validate_templates raise Ridley::Errors::CookbookSyntaxError, "Invalid template files in cookbook: #{cookbook_name} (#{version})." end true end def to_hash result = manifest.dup result[:chef_type] = CHEF_TYPE result[:name] = name result[:cookbook_name] = cookbook_name result[:version] = version result[:metadata] = metadata.to_hash result[:frozen?] = frozen result end def to_json(*args) result = self.to_hash result['json_class'] = CHEF_JSON_CLASS result.to_json(*args) end def to_s "#{cookbook_name} (#{version}) '#{path}'" end def <=>(other) [self.cookbook_name, self.version] <=> [other.cookbook_name, other.version] end private # @return [Array] attr_reader :files # @return [Ridley::Chef::Chefignore, nil] attr_reader :chefignore def clear_files @files = Array.new @manifest = Hashie::Mash.new( recipes: Array.new, definitions: Array.new, libraries: Array.new, attributes: Array.new, files: Array.new, templates: Array.new, resources: Array.new, providers: Array.new, root_files: Array.new ) end def load_files load_shallow(:recipes, 'recipes', '*.rb') load_shallow(:definitions, 'definitions', '*.rb') load_shallow(:attributes, 'attributes', '*.rb') load_recursively(:libraries, 'libraries', '*.rb') load_recursively(:files, "files", "*") load_recursively(:templates, "templates", "*") load_recursively(:resources, "resources", "*.rb") load_recursively(:providers, "providers", "*.rb") load_root end def load_root [].tap do |files| Dir.glob(path.join('*'), File::FNM_DOTMATCH).each do |file| next if File.directory?(file) next if ignored?(file) @files << file @manifest[:root_files] << file_metadata(:root_files, file) end end end def load_recursively(category, category_dir, glob) [].tap do |files| file_spec = path.join(category_dir, '**', glob) Dir.glob(file_spec, File::FNM_DOTMATCH).each do |file| next if File.directory?(file) next if ignored?(file) @files << file @manifest[category] << file_metadata(category, file) end end end def load_shallow(category, *path_glob) [].tap do |files| Dir[path.join(*path_glob)].each do |file| next if ignored?(file) @files << file @manifest[category] << file_metadata(category, file) end end end def syntax_checker @syntax_checker ||= Cookbook::SyntaxCheck.new(path.to_s, chefignore) end # Determine if the given file should be ignored by the chefignore # # @return [Boolean] # true if it should be ignored, false otherwise def ignored?(file) !!chefignore && chefignore.ignored?(file) end end end