lib/berkshelf/community_rest.rb



require "retryable" unless defined?(Retryable)
require "mixlib/archive" unless defined?(Mixlib::Archive)

module Berkshelf
  class CommunityREST
    class << self
      # @param [String] target
      #   file path to the tar.gz archive on disk
      # @param [String] destination
      #   file path to extract the contents of the target to
      #
      # @return [String]
      def unpack(target, destination)
        if is_gzip_file(target) || is_tar_file(target)
          Mixlib::Archive.new(target).extract(destination)
        else
          raise Berkshelf::UnknownCompressionType.new(target, destination)
        end

        destination
      end

      # @param [String] version
      #
      # @return [String]
      def uri_escape_version(version)
        version.to_s.tr(".", "_")
      end

      # @param [String] uri
      #
      # @return [String]
      def version_from_uri(uri)
        File.basename(uri.to_s).tr("_", ".")
      end

      private

      def is_gzip_file(path)
        # You cannot write "\x1F\x8B" because the default encoding of
        # ruby >= 1.9.3 is UTF-8 and 8B is an invalid in UTF-8.
        IO.binread(path, 2) == [0x1F, 0x8B].pack("C*")
      end

      def is_tar_file(path)
        IO.binread(path, 8, 257).to_s == "ustar\x0000"
      end
    end

    V1_API = "https://supermarket.chef.io".freeze

    # @return [String]
    attr_reader :api_uri
    # @return [Integer]
    #   how many retries to attempt on HTTP requests
    attr_reader :retries
    # @return [Float]
    #   time to wait between retries
    attr_reader :retry_interval
    # @return [Berkshelf::RidleyCompat]
    attr_reader :connection

    # @param [String] uri (CommunityREST::V1_API)
    #   location of community site to connect to
    #
    # @option options [Integer] :retries (5)
    #   retry requests on 5XX failures
    # @option options [Float] :retry_interval (0.5)
    #   how often we should pause between retries
    def initialize(uri = V1_API, options = {})
      options = options.dup
      options = { retries: 5, retry_interval: 0.5, ssl: Berkshelf::Config.instance.ssl }.merge(options)
      @api_uri = uri
      options[:server_url] = uri
      @retries = options.delete(:retries)
      @retry_interval = options.delete(:retry_interval)

      @connection = Berkshelf::RidleyCompatJSON.new(**options)
    end

    # Download and extract target cookbook archive to the local file system,
    # returning its filepath.
    #
    # @param [String] name
    #   the name of the cookbook
    # @param [String] version
    #   the targeted version of the cookbook
    #
    # @return [String, nil]
    #   cookbook filepath, or nil if archive does not contain a cookbook
    def download(name, version)
      archive = stream(find(name, version)["file"])
      scratch = Dir.mktmpdir
      extracted = self.class.unpack(archive.path, scratch)

      if File.cookbook?(extracted)
        extracted
      else
        Dir.glob("#{extracted}/*").find do |dir|
          File.cookbook?(dir)
        end
      end
    ensure
      archive.unlink unless archive.nil?
    end

    def find(name, version)
      body = connection.get("cookbooks/#{name}/versions/#{self.class.uri_escape_version(version)}")

      # Artifactory responds with a 200 and blank body for unknown cookbooks.
      raise CookbookNotFound.new(name, nil, "at `#{api_uri}'") if body.nil?

      body
    rescue CookbookNotFound
      raise
    rescue Berkshelf::APIClient::ServiceNotFound
      raise CookbookNotFound.new(name, nil, "at `#{api_uri}'")
    rescue
      raise CommunitySiteError.new(api_uri, "'#{name}' (#{version})")
    end

    # Returns the latest version of the cookbook and its download link.
    #
    # @return [String]
    def latest_version(name)
      body = connection.get("cookbooks/#{name}")

      # Artifactory responds with a 200 and blank body for unknown cookbooks.
      raise CookbookNotFound.new(name, nil, "at `#{api_uri}'") if body.nil?

      self.class.version_from_uri body["latest_version"]
    rescue Berkshelf::APIClient::ServiceNotFound
      raise CookbookNotFound.new(name, nil, "at `#{api_uri}'")
    rescue
      raise CommunitySiteError.new(api_uri, "the latest version of '#{name}'")
    end

    # @param [String] name
    #
    # @return [Array]
    def versions(name)
      body = connection.get("cookbooks/#{name}")

      # Artifactory responds with a 200 and blank body for unknown cookbooks.
      raise CookbookNotFound.new(name, nil, "at `#{api_uri}'") if body.nil?

      body["versions"].collect do |version_uri|
        self.class.version_from_uri(version_uri)
      end

    rescue Berkshelf::APIClient::ServiceNotFound
      raise CookbookNotFound.new(name, nil, "at `#{api_uri}'")
    rescue
      raise CommunitySiteError.new(api_uri, "versions of '#{name}'")
    end

    # @param [String] name
    # @param [String, Semverse::Constraint] constraint
    #
    # @return [String]
    def satisfy(name, constraint)
      Semverse::Constraint.satisfy_best(constraint, versions(name)).to_s
    rescue Semverse::NoSolutionError
      nil
    end

    # Stream the response body of a remote URL to a file on the local file system
    #
    # @param [String] target
    #   a URL to stream the response body from
    #
    # @return [Tempfile]
    def stream(target)
      local = Tempfile.new("community-rest-stream")
      local.binmode
      Retryable.retryable(tries: retries, on: Berkshelf::APIClientError, sleep: retry_interval) do
        connection.streaming_request(target, {}, local)
      end
    ensure
      local.close(false) unless local.nil?
    end
  end
end