lib/chef-cli/policyfile/cookbook_locks.rb



#
# Copyright:: Copyright (c) 2014-2018 Chef Software Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "forwardable" unless defined?(Forwardable)

require_relative "../exceptions"

require_relative "../cookbook_profiler/null_scm"
require_relative "../cookbook_profiler/git"

require_relative "../cookbook_profiler/identifiers"
require_relative "storage_config"

require_relative "cookbook_location_specification"

module ChefCLI

  module Policyfile

    # Base class for CookbookLock implementations
    class CookbookLock

      REQUIRED_LOCK_DATA_KEYS = %w{version identifier dotted_decimal_identifier cache_key source_options}.freeze
      REQUIRED_LOCK_DATA_KEYS.each(&:freeze)
      REQUIRED_LOCK_DATA_KEYS.freeze

      include Policyfile::StorageConfigDelegation

      # The cookbook name (without any version or other info suffixed)
      attr_reader :name

      # Options specifying the source and revision of this cookbook. These can
      # be passed to a CookbookLocationSpecification to create an object that can install the
      # same revision of the cookbook on another machine.
      attr_accessor :source_options

      # A string that uniquely identifies the cookbook version. If not
      # explicitly set, an identifier is generated based on the cookbook's
      # content.
      attr_accessor :identifier

      # A string in "X.Y.Z" version number format that uniquely identifies the
      # cookbook version. This is for compatibility with Chef Infra Server 11.x,
      # where cookbooks are stored by x.y.z version numbers.
      attr_accessor :dotted_decimal_identifier

      attr_reader :storage_config

      attr_accessor :version

      def initialize(name, storage_config)
        @name = name
        @version = nil
        @source_options = nil
        @identifier = nil
        @dotted_decimal_identifier = nil
        @storage_config = storage_config
      end

      def installed?
        cookbook_location_spec.installed?
      end

      def install_locked
        cookbook_location_spec.ensure_cached
      end

      def cookbook_location_spec
        raise InvalidCookbookLockData, "Cannot create CookbookLocationSpecification for #{name} without version" if version.nil?
        raise InvalidCookbookLockData, "Cannot create CookbookLocationSpecification for #{name} without source options" if source_options.nil?

        @location_spec ||= CookbookLocationSpecification.new(name, "= #{version}", source_options, storage_config)
      end

      def dependencies
        cookbook_location_spec.dependencies
      end

      def gather_profile_data
        @identifier ||= identifiers.content_identifier
        @dotted_decimal_identifier ||= identifiers.dotted_decimal_identifier
        @version ||= identifiers.semver_version
      end

      def identifiers
        @identifiers ||= CookbookProfiler::Identifiers.new(cookbook_version)
      end

      def cookbook_path
        raise NotImplementedError, "#{self.class} must override #cookbook_path with a specific implementation"
      end

      def to_lock
        validate!
        lock_data
      end

      def lock_data
        raise NotImplementedError, "#{self.class} must override #lock_data a specific implementation"
      end

      def build_from_lock_data(lock_data)
        raise NotImplementedError, "#{self.class} must override #build_from_lock_data with a specific implementation"
      end

      def validate!
        raise NotImplementedError, "#{self.class} must override #validate! with a specific implementation"
      end

      def refresh!
        raise NotImplementedError, "#{self.class} must override #refresh! with a specific implementation"
      end

      def updated?
        false
      end

      def identifier_updated?
        false
      end

      def version_updated?
        false
      end

      def symbolize_source_options_keys(source_options_from_json)
        source_options_from_json ||= {}
        source_options_from_json.inject({}) do |normalized_source_opts, (key, value)|
          normalized_source_opts[key.to_sym] = value
          normalized_source_opts
        end
      end

      def cookbook_version
        @cookbook_version ||= cookbook_loader.cookbook_version
      end

      def cookbook_loader
        @cookbook_loader ||=
          begin
            loader = Chef::Cookbook::CookbookVersionLoader.new(cookbook_path, chefignore)
            loader.load!
            loader
          end
      end

      def chefignore
        @chefignore ||= Chef::Cookbook::Chefignore.new(File.join(cookbook_path, "chefignore"))
      end

      private

      def assert_required_keys_valid!(lock_data)
        missing_keys = REQUIRED_LOCK_DATA_KEYS.reject { |key| lock_data.key?(key) }
        unless missing_keys.empty?
          raise InvalidLockfile, "Lockfile cookbook_lock for #{name} missing required attributes `#{missing_keys.join("', `")}'"
        end

        version = lock_data["version"]
        unless version.is_a?(String)
          raise InvalidLockfile, "Lockfile cookbook_lock for #{name} `version' attribute must be a string (got: #{version})"
        end

        identifier = lock_data["identifier"]
        unless identifier.is_a?(String)
          raise InvalidLockfile, "Lockfile cookbook_lock for #{name} `identifier' attribute must be a string (got: #{identifier})"
        end

        cache_key = lock_data["cache_key"]
        unless cache_key.is_a?(String) || cache_key.nil?
          raise InvalidLockfile, "Lockfile cookbook_lock for #{name} `cache_key' attribute must be a string (got: #{cache_key})"
        end

        source_options = lock_data["source_options"]
        unless source_options.is_a?(Hash)
          raise InvalidLockfile, "Lockfile cookbook_lock for #{name} `source_options' attribute must be a Hash (JSON object) (got: #{source_options})"
        end
      end

    end

    # CachedCookbook objects represent a cookbook that has been fetched from an
    # upstream canonical source and stored (presumed unmodified).
    class CachedCookbook < CookbookLock

      # The directory name in the cookbook cache where the cookbook is stored.
      # By convention, this should be the name of the cookbook followed by a
      # hyphen and then some sort of version identifier (depending on the
      # cookbook source).
      attr_accessor :cache_key

      # A URI pointing to the canonical source of the cookbook.
      attr_accessor :origin

      def initialize(name, storage_config)
        @name = name
        @version = nil
        @origin = nil
        @source_options = nil
        @cache_key = nil
        @identifier = nil
        @dotted_decimal_identifier = nil
        @storage_config = storage_config
      end

      def cookbook_path
        if cache_key.nil?
          raise MissingCookbookLockData, "Cannot locate cached cookbook `#{name}' because the `cache_key' attribute is not set"
        end

        File.join(cache_path, cache_key)
      end

      def build_from_lock_data(lock_data)
        assert_required_keys_valid!(lock_data)

        @version = lock_data["version"]
        @identifier = lock_data["identifier"]
        @dotted_decimal_identifier = lock_data["dotted_decimal_identifier"]
        @cache_key = lock_data["cache_key"]
        @origin = lock_data["origin"]
        @source_options = symbolize_source_options_keys(lock_data["source_options"])
      end

      def lock_data
        {
          "version" => version,
          "identifier" => identifier,
          "dotted_decimal_identifier" => dotted_decimal_identifier,
          "cache_key" => cache_key,
          "origin" => origin,
          "source_options" => source_options,
        }
      end

      def validate!
        if cache_key.nil?
          raise CachedCookbookNotFound, "Cookbook `#{name}' does not have a `cache_key` set, cannot locate cookbook"
        end
        unless File.exist?(cookbook_path)
          raise CachedCookbookNotFound, "Cookbook `#{name}' not found at expected cache location `#{cache_key}' (full path: `#{cookbook_path}')"
        end
      end

      # We do not expect the cookbook to get mutated out-of-band, so refreshing
      # the data generally should have no affect. If the cookbook has been
      # mutated, though, then a CachedCookbookModified exception is raised.
      def refresh!
        # This behavior fits better with the intent of the #validate! method,
        # but we cannot check for modifications there because the user may be
        # setting custom identifiers.
        if @identifier && identifiers.content_identifier != @identifier
          message = "Cached cookbook `#{name}' (#{version}) has been modified since the lockfile was generated. " +
            "Cached cookbooks cannot be modified. (full path: `#{cookbook_path}')"
          raise CachedCookbookModified, message
        end
      end

    end

    # LocalCookbook objects represent cookbooks that are sourced from the local
    # filesystem and are assumed to be under active development.
    class LocalCookbook < CookbookLock

      # A relative or absolute path to the cookbook. If a relative path is
      # given, it is resolved relative to #relative_paths_root
      attr_accessor :source

      def initialize(name, storage_config)
        @name = name
        @identifier = nil
        @storage_config = storage_config

        @identifier_updated = false
        @version_updated = false
        @cookbook_in_git_repo = nil
        @scm_info = nil
      end

      def cookbook_path
        File.expand_path(source, relative_paths_root)
      end

      def scm_profiler
        if cookbook_in_git_repo?
          CookbookProfiler::Git.new(cookbook_path)
        else
          CookbookProfiler::NullSCM.new(cookbook_path)
        end
      end

      def scm_info
        @scm_info
      end

      def to_lock
        refresh_scm_info
        super
      end

      def lock_data
        {
          "version" => version,
          "identifier" => identifier,
          "dotted_decimal_identifier" => dotted_decimal_identifier,
          "source" => source,
          "cache_key" => nil,
          "scm_info" => scm_info,
          "source_options" => source_options,
        }
      end

      def build_from_lock_data(lock_data)
        assert_required_keys_valid!(lock_data)

        @version = lock_data["version"]
        @identifier = lock_data["identifier"]
        @dotted_decimal_identifier = lock_data["dotted_decimal_identifier"]
        @source = lock_data["source"]
        @source_options = symbolize_source_options_keys(lock_data["source_options"])
        @scm_info = lock_data["scm_info"]
      end

      def validate!
        if source.nil?
          raise LocalCookbookNotFound, "Cookbook `#{name}' does not have a `source` set, cannot locate cookbook"
        end
        unless File.exist?(cookbook_path)
          raise LocalCookbookNotFound, "Cookbook `#{name}' not found at path source `#{source}` (full path: `#{cookbook_path}')"
        end

        unless cookbook_version.name.to_s == name
          msg = "The cookbook at path source `#{source}' is expected to be named `#{name}', but is now named `#{cookbook_version.name}' (full path: #{cookbook_path})"
          raise MalformedCookbook, msg
        end
      end

      def refresh!
        old_identifier, old_version = @identifier, @version
        @identifier, @dotted_decimal_identifier, @version = nil, nil, nil
        gather_profile_data
        if @identifier != old_identifier
          @identifier_updated = true
        end
        if @version != old_version
          @version_updated = true
        end
        self
      end

      def updated?
        @identifier_updated || @version_updated
      end

      def version_updated?
        @version_updated
      end

      def identifier_updated?
        @identifier_updated
      end

      private

      def refresh_scm_info
        @scm_info = scm_profiler.profile_data
      end

      def assert_required_keys_valid!(lock_data)
        super

        source = lock_data["source"]
        if source.nil?
          raise InvalidLockfile, "Lockfile cookbook_lock for #{name} is invalid. Lock data for a local cookbook must have a `source' attribute"
        end

        unless source.is_a?(String)
          raise InvalidLockfile, "Lockfile cookbook_lock for #{name} is invalid: `source' attribute must be a String (got: #{source.inspect})"
        end
      end

      def cookbook_in_git_repo?
        return @cookbook_in_git_repo unless @cookbook_in_git_repo.nil?

        @cookbook_in_git_repo = false

        dot_git = Pathname.new(".git")
        Pathname.new(cookbook_path).ascend do |parent_dir|
          possible_git_dir = parent_dir + dot_git
          if possible_git_dir.exist?
            @cookbook_in_git_repo = true
            break
          end
        end

        @cookbook_in_git_repo
      end

    end

    class ArchivedCookbook < CookbookLock

      extend Forwardable

      def_delegator :@archived_lock, :name
      def_delegator :@archived_lock, :source_options
      def_delegator :@archived_lock, :identifier
      def_delegator :@archived_lock, :dotted_decimal_identifier
      def_delegator :@archived_lock, :version
      def_delegator :@archived_lock, :source

      # #to_lock calls #validate! which will typically ensure that the cookbook
      # is present at the correct path for the lock type. For an archived
      # cookbook, the cookbook is located in the archive, so it probably won't
      # exist there. Therefore, we bypass #validate! and get the lock data
      # directly.
      def_delegator :@archived_lock, :lock_data, :to_lock

      def initialize(archived_lock, storage_config)
        @archived_lock = archived_lock
        @storage_config = storage_config
      end

      def build_from_lock_data(lock_data)
        raise NotImplementedError, "ArchivedCookbook cannot be built from lock data, it can only wrap an existing lock object"
      end

      def installed?
        File.exist?(cookbook_path) && File.directory?(cookbook_path)
      end

      # The cookbook is assumed to be stored in a Chef Zero compatible repo as
      # created by `chef export`. Currently that only creates "compatibility
      # mode" repos since Chef Zero doesn't yet support cookbook_artifact APIs.
      # So the cookbook will be located in a path like:
      #   cookbooks/nginx-111.222.333
      def cookbook_path
        File.join(relative_paths_root, "cookbook_artifacts", "#{name}-#{identifier}")
      end

      # We trust that archived cookbooks haven't been modified, so just return
      # true for #validate!
      def validate!
        true
      end

      # We trust that archived cookbooks haven't been modified, so just return
      # true for #refresh!
      def refresh!
        true
      end
    end

  end
end