lib/berkshelf/berksfile.rb



require_relative "packager"

require "chef/cookbook/chefignore"
require "chef/util/path_helper"

module Berkshelf
  class Berksfile
    class << self
      # Instantiate a Berksfile from the given options. This method is used
      # heavily by the CLI to reduce duplication.
      #
      # @param (see Berksfile#initialize)
      def from_options(options = {})
        options[:berksfile] ||= File.join(Dir.pwd, Berkshelf::DEFAULT_FILENAME)
        symbolized = Hash[options.map { |k, v| [k.to_sym, v] }]
        from_file(options[:berksfile], symbolized.select { |k,| %i{except only delete}.include? k })
      end

      # @param [#to_s] file
      #   a path on disk to a Berksfile to instantiate from
      #
      # @return [Berksfile]
      def from_file(file, options = {})
        raise BerksfileNotFound.new(file) unless File.exist?(file)

        begin
          new(file, options).evaluate_file(file)
        rescue => ex
          raise BerksfileReadError.new(ex)
        end
      end
    end

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

    # Don't vendor VCS files.
    # Reference GNU tar --exclude-vcs: https://www.gnu.org/software/tar/manual/html_section/tar_49.html
    EXCLUDED_VCS_FILES_WHEN_VENDORING = [".arch-ids", "{arch}", ".bzr", ".bzrignore", ".bzrtags", "CVS", ".cvsignore", "_darcs", ".git", ".hg", ".hgignore", ".hgrags", "RCS", "SCCS", ".svn", "**/.git", "**/.svn"].freeze

    include Mixin::Logging
    include Cleanroom
    extend Forwardable

    # @return [String]
    #   The path on disk to the file representing this instance of Berksfile
    attr_reader :filepath

    # @return [Symbol]
    #   The solver engine required by this instance of Berksfile
    attr_reader :required_solver

    # @return [Symbol]
    #   The solver engine preferred by this instance of Berksfile
    attr_reader :preferred_solver

    # Create a new Berksfile object.
    #
    # @param [String] path
    #   path on disk to the file containing the contents of this Berksfile
    #
    # @option options [Symbol, Array<String>] :except
    #   Group(s) to exclude which will cause any dependencies marked as a member of the
    #   group to not be installed
    # @option options [Symbol, Array<String>] :only
    #   Group(s) to include which will cause any dependencies marked as a member of the
    #   group to be installed and all others to be ignored
    def initialize(path, options = {})
      @filepath         = File.expand_path(path)
      @dependencies     = {}
      @sources          = {}
      @delete           = options[:delete]

      # defaults for what solvers to use
      @required_solver  = nil
      @preferred_solver = :gecode

      if options[:except] && options[:only]
        raise ArgumentError, "Cannot specify both :except and :only!"
      elsif options[:except]
        except = Array(options[:except]).collect(&:to_sym)
        @filter = ->(dependency) { (except & dependency.groups).empty? }
      elsif options[:only]
        only = Array(options[:only]).collect(&:to_sym)
        @filter = ->(dependency) { !(only & dependency.groups).empty? }
      else
        @filter = ->(dependency) { true }
      end
    end

    # Activate a Berkshelf extension at runtime.
    #
    # @example Activate the Mercurial extension
    #   extension 'hg'
    #
    # @raise [LoadError]
    #   if the extension cannot be loaded
    #
    # @param [String] name
    #   the name of the extension to activate
    #
    # @return [true]
    def extension(name)
      require "berkshelf/#{name}"
      true
    rescue LoadError
      raise LoadError, "Could not load an extension by the name `#{name}'. " \
        "Please make sure it is installed."
    end
    expose :extension

    # Add a cookbook dependency to the Berksfile to be retrieved and have its dependencies recursively retrieved
    # and resolved.
    #
    # @example a cookbook dependency that will be retrieved from one of the default locations
    #   cookbook 'artifact'
    #
    # @example a cookbook dependency that will be retrieved from a path on disk
    #   cookbook 'artifact', path: '/Users/reset/code/artifact'
    #
    # @example a cookbook dependency that will be retrieved from a Git server
    #   cookbook 'artifact', git: 'https://github.com/chef/artifact-cookbook.git'
    #
    # @overload cookbook(name, version_constraint, options = {})
    #   @param [#to_s] name
    #   @param [#to_s] version_constraint
    #
    #   @option options [Symbol, Array] :group
    #     the group or groups that the cookbook belongs to
    #   @option options [String] :path
    #     a filepath to the cookbook on your local disk
    #   @option options [String] :git
    #     the Git URL to clone
    #
    #   @see PathLocation
    #   @see GitLocation
    # @overload cookbook(name, options = {})
    #   @param [#to_s] name
    #
    #   @option options [Symbol, Array] :group
    #     the group or groups that the cookbook belongs to
    #   @option options [String] :path
    #     a filepath to the cookbook on your local disk
    #   @option options [String] :git
    #     the Git URL to clone
    #
    #   @see PathLocation
    #   @see GitLocation
    def cookbook(*args)
      options = args.last.is_a?(Hash) ? args.pop : {}
      name, constraint = args

      options[:path] &&= File.expand_path(options[:path], File.dirname(filepath))
      options[:group] = Array(options[:group])

      if @active_group
        options[:group] += @active_group
      end

      add_dependency(name, constraint, **options)
    end
    expose :cookbook

    def group(*args)
      @active_group = args
      yield
      @active_group = nil
    end
    expose :group

    # Use a Cookbook metadata file to determine additional cookbook dependencies to retrieve. All
    # dependencies found in the metadata will use the default locations set in the Berksfile (if any are set)
    # or the default locations defined by Berkshelf.
    #
    # @param [Hash] options
    #
    # @option options [String] :path
    #   path to the metadata file
    def metadata(options = {})
      path = options[:path] || File.dirname(filepath)

      loader = Chef::Cookbook::CookbookVersionLoader.new(path)
      loader.load!
      cookbook_version = loader.cookbook_version
      metadata = cookbook_version.metadata

      add_dependency(metadata.name, nil, path: path, metadata: true)
    end
    expose :metadata

    # Add a Berkshelf API source to use when building the index of known cookbooks. The indexes will be
    # searched in the order they are added. If a cookbook is found in the first source then a cookbook
    # in a second source would not be used.
    #
    # @example
    #   source "https://supermarket.chef.io"
    #   source "https://berks-api.riotgames.com"
    #
    # @param [String] api_url
    #   url for the api to add
    #
    # @param [Hash] options
    #   extra source options
    #
    # @raise [InvalidSourceURI]
    #
    # @return [Array<Source>]
    def source(api_url, **options)
      source = Source.new(self, api_url, **options)
      @sources[source.uri.to_s] = source
    end
    expose :source

    # Configure a specific engine for the 'solve' gem to use when computing dependencies. You may
    # optionally specify how strong a requirement this is. If omitted, the default precedence is
    # :preferred.
    #
    # If :required is specified and cannot be loaded, Resolver#resolve will raise an ArgumentError.
    # If :preferred is specified and cannot be loaded, Resolver#resolve silently catch any errors and
    # use whatever default method the 'solve' gem provides (as of 2.0.1, solve defaults to :ruby).
    #
    # @example
    #   solver :gecode
    #   solver :gecode, :preferred
    #   solver :gecode, :required
    #   solver :ruby
    #   solver :ruby, :preferred
    #   solver :ruby, :required
    #
    # @param [Symbol] name
    #   name of engine for solver gem to use for depsolving
    #
    # @param [Symbol] precedence
    #   how strong a requirement using this solver is
    #   valid values are :required, :preferred
    #
    # @raise [ArgumentError]
    def solver(name, precedence = :preferred)
      if name && precedence == :required
        @required_solver = name
      elsif name && precedence == :preferred
        @preferred_solver = name
      else
        raise ArgumentError, "Invalid solver precedence ':#{precedence}'"
      end
    end
    expose :solver

    # @return [Array<Source>]
    def sources
      if @sources.empty?
        raise NoAPISourcesDefined
      else
        @sources.values
      end
    end

    # @param [Dependency] dependency
    #   the dependency to find the source for
    def source_for(name, version)
      sources.find { |source| source.cookbook(name, version) }
    end

    # Add a dependency of the given name and constraint to the array of dependencies.
    #
    # @param [String] name
    #   the name of the dependency to add
    # @param [String, Semverse::Constraint] constraint
    #   the constraint to lock the dependency to
    #
    # @option options [Symbol, Array] :group
    #   the group or groups that the cookbook belongs to
    # @option options [String] :path
    #   a filepath to the cookbook on your local disk
    # @option options [String] :git
    #   the Git URL to clone
    #
    # @raise [DuplicateDependencyDefined] if a dependency is added whose name conflicts
    #   with a dependency who has already been added.
    #
    # @return [Array<Dependency]
    def add_dependency(name, constraint = nil, options = {})
      if @dependencies[name]
        # Only raise an exception if the dependency is a true duplicate
        groups = (options[:group].nil? || options[:group].empty?) ? [:default] : options[:group]
        unless (@dependencies[name].groups & groups).empty?
          raise DuplicateDependencyDefined.new(name)
        end
      end

      # this appears to be dead code
      # if options[:path]
      #  metadata_file = File.join(options[:path], "metadata.rb")
      # end

      options[:constraint] = constraint

      @dependencies[name] = Dependency.new(self, name, options)
    end

    # Check if the Berksfile has the given dependency, taking into account
    # +group+ and --only/--except flags.
    #
    # @param [String, Dependency] dependency
    #   the dependency or name of dependency to check presence of
    #
    # @return [Boolean]
    def has_dependency?(dependency)
      name = Dependency.name(dependency)
      dependencies.map(&:name).include?(name)
    end

    # @return [Array<Dependency>]
    def dependencies
      @dependencies.values.sort.select(&@filter)
    end

    #
    # Behaves the same as {Berksfile#dependencies}, but this method returns an
    # array of CachedCookbook objects instead of dependency objects. This method
    # relies on the {Berksfile#retrieve_locked} method to load the proper
    # cached cookbook from the Berksfile + lockfile combination.
    #
    # @see [Berksfile#dependencies]
    #   for a description of the +options+ hash
    # @see [Berksfile#retrieve_locked]
    #   for a list of possible exceptions that might be raised and why
    #
    # @return [Array<CachedCookbook>]
    #
    def cookbooks
      dependencies.map { |dependency| retrieve_locked(dependency) }
    end

    # Find a dependency defined in this berksfile by name.
    #
    # @param [String] name
    #   the name of the cookbook dependency to search for
    # @return [Dependency, nil]
    #   the cookbook dependency, or nil if one does not exist
    def find(name)
      @dependencies[name]
    end

    # @return [Hash]
    #   a hash containing group names as keys and an array of Dependencies
    #   that are a member of that group as values
    #
    #   Example:
    #     {
    #       nautilus: [
    #         #<Dependency: nginx (~> 1.0.0)>,
    #         #<Dependency: mysql (~> 1.2.4)>
    #       ],
    #       skarner: [
    #         #<Dependency: nginx (~> 1.0.0)>
    #       ]
    #     }
    def groups
      {}.tap do |groups|
        dependencies.each do |dependency|
          dependency.groups.each do |group|
            groups[group] ||= []
            groups[group] << dependency
          end
        end
      end
    end

    # @param [String] name
    #   name of the dependency to return
    #
    # @return [Dependency]
    def [](name)
      @dependencies[name]
    end
    alias_method :get_dependency, :[]

    # Install the dependencies listed in the Berksfile, respecting the locked
    # versions in the Berksfile.lock.
    #
    # 1. Check that a lockfile exists. If a lockfile does not exist, all
    #    dependencies are considered to be "unlocked". If a lockfile is specified, a
    #    definition is created via the following algorithm:
    #
    #    - For each source, see if there exists a locked version that still
    #      satisfies the version constraint in the Berksfile. If
    #      there exists such a source, remove it from the list of unlocked
    #      sources. If not, then either a version constraint has changed,
    #      or a new source has been added to the Berksfile. In the event that
    #      a locked_source exists, but it no longer satisfies the constraint,
    #      this method will raise a {OutdatedCookbookSource}, and
    #      inform the user to run <tt>berks update COOKBOOK</tt> to remedy the issue.
    #    - Remove any locked sources that no longer exist in the Berksfile
    #      (i.e. a cookbook source was removed from the Berksfile).
    #
    # 2. Resolve the collection of locked and unlocked dependencies.
    #
    # 3. Write out a new lockfile.
    #
    # @raise [OutdatedDependency]
    #   if the lockfile constraints do not satisfy the Berksfile constraints
    #
    # @return [Array<CachedCookbook>]
    def install
      Installer.new(self).run
    end

    # Update the given set of dependencies (or all if no names are given).
    #
    # @option options [String, Array<String>] :cookbooks
    #   Names of the cookbooks to retrieve dependencies for
    def update(*names)
      validate_lockfile_present!
      validate_cookbook_names!(names)

      Berkshelf.log.info "Updating cookbooks"

      # Calculate the list of cookbooks to unlock
      if names.empty?
        Berkshelf.log.debug "  Unlocking all the things!"
        lockfile.unlock_all
      else
        names.each do |name|
          Berkshelf.log.debug "  Unlocking #{name}"
          lockfile.unlock(name, true)
        end
      end

      # NOTE: We intentionally do NOT pass options to the installer
      install
    end

    # Retrieve information about a given cookbook that is installed by this Berksfile.
    # Unlike {#find}, which returns a dependency, this method returns the corresponding
    # CachedCookbook for the given name.
    #
    # @raise [LockfileNotFound]
    #   if there is no lockfile containing that cookbook
    # @raise [CookbookNotFound]
    #   if there is a lockfile with a cookbook, but the cookbook is not downloaded
    #
    # @param [Dependency] name
    #   the name of the cookbook to find
    #
    # @return [CachedCookbook]
    #   the CachedCookbook that corresponds to the given name parameter
    def retrieve_locked(dependency)
      lockfile.retrieve(dependency)
    end

    # The cached cookbooks installed by this Berksfile.
    #
    # @raise [LockfileNotFound]
    #   if there is no lockfile
    # @raise [CookbookNotFound]
    #   if a listed source could not be found
    #
    # @return [Hash<Dependency, CachedCookbook>]
    #   the list of dependencies as keys and the cached cookbook as the value
    def list
      validate_lockfile_present!
      validate_lockfile_trusted!
      validate_dependencies_installed!

      lockfile.graph.locks.values
    end

    # List of all the cookbooks which have a newer version found at a source
    # that satisfies the constraints of your dependencies.
    #
    # @param [Boolean] include_non_satisfying
    #   include cookbooks that would not satisfy the given constraints in the
    #   +Berksfile+. Defaults to false.
    #
    # @return [Hash]
    #   a hash of cached cookbooks and their latest version grouped by their
    #   remote API source. The hash will be empty if there are no newer
    #   cookbooks for any of your dependencies (that still satisfy the given)
    #   constraints in the +Berksfile+.
    #
    # @example
    #   berksfile.outdated #=> {
    #     "nginx" => {
    #       "local" => #<Version 1.8.0>,
    #       "remote" => {
    #         #<Source uri: "https://supermarket.chef.io"> #=> #<Version 2.6.2>
    #       }
    #     }
    #   }
    def outdated(*names, include_non_satisfying: false)
      validate_lockfile_present!
      validate_lockfile_trusted!
      validate_dependencies_installed!
      validate_cookbook_names!(names)

      lockfile.graph.locks.inject({}) do |hash, (name, dependency)|
        sources.each do |source|
          cookbooks = source.versions(name)

          latest = cookbooks.select do |cookbook|
            (include_non_satisfying || dependency.version_constraint.satisfies?(cookbook.version)) &&
              Semverse::Version.coerce(cookbook.version) > dependency.locked_version
          end.max_by(&:version)

          unless latest.nil?
            hash[name] ||= {
              "local" => dependency.locked_version,
              "remote" => {
                source => Semverse::Version.coerce(latest.version),
              },
            }
          end
        end

        hash
      end
    end

    # Upload the cookbooks installed by this Berksfile
    #
    # @overload upload(names = [])
    #   @param [Array<String>] names
    #     the list of cookbooks (by name) to upload to the remote Chef Server
    #
    #
    # @overload upload(names = [], options = {})
    #   @param [Array<String>] names
    #     the list of cookbooks (by name) to upload to the remote Chef Server
    #   @param [Hash<Symbol, Object>] options
    #     the list of options to pass to the uploader
    #
    #   @option options [Boolean] :force (false)
    #     upload the cookbooks even if the version already exists and is frozen
    #     on the remote Chef Server
    #   @option options [Boolean] :freeze (true)
    #     freeze the uploaded cookbooks on the remote Chef Server so that it
    #     cannot be overwritten on future uploads
    #   @option options [Hash] :ssl_verify (true)
    #     use SSL verification while connecting to the remote Chef Server
    #   @option options [Boolean] :halt_on_frozen (false)
    #     raise an exception ({FrozenCookbook}) if one of the cookbooks already
    #     exists on the remote Chef Server and is frozen
    #   @option options [String] :server_url
    #     the URL (endpoint) to the remote Chef Server
    #   @option options [String] :client_name
    #     the client name for the remote Chef Server
    #   @option options [String] :client_key
    #     the client key (pem) for the remote Chef Server
    #
    #
    # @example Upload all cookbooks
    #   berksfile.upload
    #
    # @example Upload the 'apache2' and 'mysql' cookbooks
    #   berksfile.upload('apache2', 'mysql')
    #
    # @example Upload and freeze all cookbooks
    #   berksfile.upload(freeze: true)
    #
    # @example Upload and freeze the `chef-sugar` cookbook
    #   berksfile.upload('chef-sugar', freeze: true)
    #
    #
    # @raise [UploadFailure]
    #   if you are uploading cookbooks with an invalid or not-specified client key
    # @raise [DependencyNotFound]
    #   if one of the given cookbooks is not a dependency defined in the Berksfile
    # @raise [FrozenCookbook]
    #   if the cookbook being uploaded is a {metadata} cookbook and is already
    #   frozen on the remote Chef Server; indirect dependencies or non-metadata
    #   dependencies are just skipped
    #
    # @return [Array<CachedCookbook>]
    #   the list of cookbooks that were uploaded to the Chef Server
    def upload(*args)
      validate_lockfile_present!
      validate_lockfile_trusted!
      validate_dependencies_installed!

      Uploader.new(self, *args).run
    end

    # Package the given cookbook for distribution outside of berkshelf. If the
    # name attribute is not given, all cookbooks in the Berksfile will be
    # packaged.
    #
    # @param [String] path
    #   the path where the tarball will be created
    #
    # @raise [PackageError]
    #
    # @return [String]
    #   the path to the package
    def package(path)
      packager = Packager.new(path)
      packager.validate!

      outdir = Dir.mktmpdir do |temp_dir|
        Berkshelf.ui.mute { vendor(File.join(temp_dir, "cookbooks")) }
        packager.run(temp_dir)
      end

      Berkshelf.formatter.package(outdir)
      outdir
    end

    # backcompat with ridley lookup of chefignore
    def find_chefignore(path)
      filename = "chefignore"

      Pathname.new(path).ascend do |dir|
        next unless dir.directory?

        [
          dir.join(filename),
          dir.join("cookbooks", filename),
          dir.join(".chef",     filename),
        ].each do |possible|
          return possible.expand_path.to_s if possible.exist?
        end
      end

      nil
    end

    # Install the Berksfile or Berksfile.lock and then sync the cached cookbooks
    # into directories within the given destination matching their name.
    #
    # @param [String] destination
    #   filepath to vendor cookbooks to
    #
    # @return [String, nil]
    #   the expanded path cookbooks were vendored to or nil if nothing was vendored
    def vendor(destination)
      Dir.mktmpdir("vendor") do |scratch|
        cached_cookbooks = install

        return nil if cached_cookbooks.empty?

        cached_cookbooks.each do |cookbook|
          Berkshelf.formatter.vendor(cookbook, destination)

          cookbook_destination = File.join(scratch, cookbook.cookbook_name)
          FileUtils.mkdir_p(cookbook_destination)

          # Dir.glob does not support backslash as a File separator
          src   = cookbook.path.to_s.tr("\\", "/")
          files = FileSyncer.glob(File.join(src, "**/*"))

          # strip directories
          files.reject! { |file_path| File.directory?(file_path) }

          # convert to relative Pathname objects for chefignore
          files.map! { |file_path| Chef::Util::PathHelper.relative_path_from(cookbook.path.to_s, file_path) }

          chefignore = Chef::Cookbook::Chefignore.new(find_chefignore(cookbook.path.to_s) || cookbook.path.to_s)

          # apply chefignore
          files.reject! { |file_path| chefignore.ignored?(file_path) }

          # convert Pathname objects back to strings
          files.map!(&:to_s)

          # copy each file to destination
          files.each do |rpath|
            FileUtils.mkdir_p( File.join(cookbook_destination, File.dirname(rpath)) )
            FileUtils.cp( File.join(cookbook.path.to_s, rpath), File.join(cookbook_destination, rpath) )
          end

          cookbook.compile_metadata(cookbook_destination)
        end

        # Don't vendor the raw metadata (metadata.rb). The raw metadata is
        # unecessary for the client, and this is required until compiled metadata
        # (metadata.json) takes precedence over raw metadata in the Chef-Client.
        #
        # We can change back to including the raw metadata in the future after
        # this has been fixed or just remove these comments. There is no
        # circumstance that I can currently think of where raw metadata should
        # ever be read by the client.
        #
        # - Jamie
        #
        # See the following tickets for more information:
        #
        #   * https://tickets.opscode.com/browse/CHEF-4811
        #   * https://tickets.opscode.com/browse/CHEF-4810
        FileSyncer.sync(scratch, destination, exclude: EXCLUDED_VCS_FILES_WHEN_VENDORING, delete: @delete)
      end

      destination
    end

    # Perform a validation with `Validator#validate` on each cached cookbook associated
    # with the Lockfile of this Berksfile.
    #
    # This function will return true or raise the first errors encountered.
    def verify
      validate_lockfile_present!
      validate_lockfile_trusted!
      Berkshelf.formatter.msg "Verifying (#{lockfile.cached.length}) cookbook(s)..."
      Validator.validate(lockfile.cached)
      true
    end

    # Visualize the current Berksfile as a "graph" using DOT.
    #
    # @param [String] outfile
    #   the name/path to outfile the file
    #
    # @return [String] path
    #   the path where the image was written
    def viz(outfile = nil, format = "png")
      outfile = File.join(Dir.pwd, outfile || "graph.png")

      validate_lockfile_present!
      validate_lockfile_trusted!
      vizualiser = Visualizer.from_lockfile(lockfile)

      case format
      when "dot"
        vizualiser.to_dot_file(outfile)
      when "png"
        vizualiser.to_png(outfile)
      else
        raise ConfigurationError, "Vizualiser format #{format} not recognised."
      end
    end

    # Get the lockfile corresponding to this Berksfile. This is necessary because
    # the user can specify a different path to the Berksfile. So assuming the lockfile
    # is named "Berksfile.lock" is a poor assumption.
    #
    # @return [Lockfile]
    #   the lockfile corresponding to this berksfile, or a new Lockfile if one does
    #   not exist
    def lockfile
      @lockfile ||= Lockfile.from_berksfile(self)
    end

    private

    # Ensure the lockfile is present on disk.
    #
    # @raise [LockfileNotFound]
    #   if the lockfile does not exist on disk
    #
    # @return [true]
    def validate_lockfile_present!
      raise LockfileNotFound unless lockfile.present?

      true
    end

    # Ensure that all dependencies defined in the Berksfile exist in this
    # lockfile.
    #
    # @raise [LockfileOutOfSync]
    #   if there are dependencies specified in the Berksfile which do not
    #   exist (or are not satisifed by) the lockfile
    #
    # @return [true]
    def validate_lockfile_trusted!
      raise LockfileOutOfSync unless lockfile.trusted?

      true
    end

    # Ensure that all dependencies in the lockfile are installed on this
    # system. You should validate that the lockfile can be trusted before
    # using this method.
    #
    # @raise [DependencyNotInstalled]
    #   if the dependency in the lockfile is not in the Berkshelf shelf on
    #   this system
    #
    # @return [true]
    def validate_dependencies_installed!
      lockfile.graph.locks.each do |_, dependency|
        unless dependency.installed?
          raise DependencyNotInstalled.new(dependency)
        end
      end

      true
    end

    # Determine if any cookbooks were specified that aren't in our shelf.
    #
    # @param [Array<String>] names
    #   a list of cookbook names
    #
    # @raise [DependencyNotFound]
    #   if a cookbook name is given that does not exist
    def validate_cookbook_names!(names)
      missing = names - lockfile.graph.locks.keys

      unless missing.empty?
        raise DependencyNotFound.new(missing)
      end
    end
  end
end