berkshelf/ridley

View on GitHub
lib/ridley/chef/cookbook.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# 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