File Manager

Path: /opt/chef.upgrade/embedded/lib/ruby/gems/2.3.0/gems/chef-12.21.12/lib/chef/provider/package/

Viewing File: yum.rb

# Author:: Adam Jacob (<adam@chef.io>)
# Copyright:: Copyright 2008-2017, 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 "chef/config"
require "chef/provider/package"
require "chef/resource/yum_package"
require "chef/mixin/get_source_from_package"
require "chef/provider/package/yum/rpm_utils"
require "chef/provider/package/yum/yum_cache"

class Chef
  class Provider
    class Package
      class Yum < Chef::Provider::Package
        include Chef::Mixin::GetSourceFromPackage

        provides :package, platform_family: %w{rhel fedora}
        provides :yum_package, os: "linux"

        # Multipackage API
        allow_nils
        use_multipackage_api
        use_package_name_for_source

        # Overload the Package provider to keep track of the YumCache
        def initialize(new_resource, run_context)
          super

          @yum = YumCache.instance
          @yum.yum_binary = yum_binary
        end

        # @see Chef::Provider::Package#check_resource_semantics!
        def check_resource_semantics!
          super

          if !new_resource.version.nil? && package_name_array.length != new_version_array.length
            raise Chef::Exceptions::InvalidResourceSpecification, "Please provide a version for each package. Use `nil` for default version."
          end

          if !new_resource.arch.nil? && package_name_array.length != safe_arch_array.length
            raise Chef::Exceptions::InvalidResourceSpecification, "Please provide an architecture for each package. Use `nil` for default architecture."
          end
        end

        # @see Chef::Provider#define_resource_requirements
        def define_resource_requirements
          super

          # Ensure that the source file (if specified) is present on the file system
          requirements.assert(:install, :upgrade, :remove, :purge) do |a|
            a.assertion { !new_resource.source || ::File.exist?(new_resource.source) }
            a.failure_message Chef::Exceptions::Package, "Package #{new_resource.package_name} not found: #{new_resource.source}"
            a.whyrun "assuming #{new_resource.source} would have previously been created"
          end
        end

        # @see Chef::Provider#load_current_resource
        def load_current_resource
          @yum.reload if flush_cache[:before]
          manage_extra_repo_control

          if new_resource.source
            query_source_file
          else
            # At this point package_name could be:
            #
            # 1) a package name, eg: "foo"
            # 2) a package name.arch, eg: "foo.i386"
            # 3) or a dependency, eg: "foo >= 1.1"
            #
            # In the third case, we want to convert those dependency strings into
            # packages that we can actually install
            convert_dependency_strings_into_packages

            # Fill out the rest of the details by querying the Yum Cache
            query_yum_cache
          end

          @current_resource = Chef::Resource::YumPackage.new(new_resource.name)
          current_resource.package_name(new_resource.package_name)
          current_resource.version(@installed_version)
          current_resource
        end

        # @see Chef::Provider::Package#package_locked
        def package_locked(name, version)
          islocked = false
          locked = shell_out_with_timeout!("yum versionlock")
          locked.stdout.each_line do |line|
            line_package = line.sub(/-[^-]*-[^-]*$/, "").split(":").last.strip
            if line_package == name
              islocked = true
            end
          end
          islocked
        end

        #
        # Package Action Classes
        #

        # @see Chef::Provider::Package#install_package
        def install_package(name, version)
          if new_resource.source
            yum_command("-d0 -e0 -y#{expand_options(new_resource.options)} localinstall #{new_resource.source}")
          else
            install_remote_package(name, version)
          end

          flush_cache[:after] ? @yum.reload : @yum.reload_installed
        end

        # @see Chef::Provider::Package#upgrade_package
        def upgrade_package(name, version)
          install_package(name, version)
        end

        # @see Chef::Provider::Package#remove_package
        def remove_package(name, version)
          remove_str = full_package_name(name, version).join(" ")
          yum_command("-d0 -e0 -y#{expand_options(new_resource.options)} remove #{remove_str}")

          flush_cache[:after] ? @yum.reload : @yum.reload_installed
        end

        # @see Chef::Provider::Package#purge_package
        def purge_package(name, version)
          remove_package(name, version)
        end

        # @see Chef::Provider::Package#lock_package
        def lock_package(name, version)
          lock_str = full_package_name(name, as_array(name).map { nil }).join(" ")
          yum_command("-d0 -e0 -y#{expand_options(new_resource.options)} versionlock add #{lock_str}")
        end

        # @see Chef::Provider::Package#unlock_package
        def unlock_package(name, version)
          unlock_str = full_package_name(name, as_array(name).map { nil }).join(" ")
          yum_command("-d0 -e0 -y#{expand_options(new_resource.options)} versionlock delete #{unlock_str}")
        end

        # Keep upgrades from trying to install an older candidate version. Can happen when a new
        # version is installed then removed from a repository, now the older available version
        # shows up as a viable install candidate.
        #
        # Can be done in upgrade_package but an upgraded from->to log message slips out
        #
        # Hacky - better overall solution? Custom compare in Package provider?
        def action_upgrade
          # Could be uninstalled or have no candidate
          if current_resource.version.nil? || !candidate_version_array.any?
            super
          elsif candidate_version_array.zip(current_version_array).any? do |c, i|
                  RPMVersion.parse(c) > RPMVersion.parse(i)
                end
            super
          else
            Chef::Log.debug("#{new_resource} is at the latest version - nothing to do")
          end
        end

        private

        #
        # System Level Yum Operations
        #

        def yum_binary
          @yum_binary ||=
            begin
              yum_binary = new_resource.yum_binary if new_resource.is_a?(Chef::Resource::YumPackage)
              yum_binary ||= ::File.exist?("/usr/bin/yum-deprecated") ? "yum-deprecated" : "yum"
            end
        end

        # Enable or disable YumCache extra_repo_control
        def manage_extra_repo_control
          if new_resource.options
            repo_control = []
            new_resource.options.split.each do |opt|
              repo_control << opt if opt =~ /--(enable|disable)repo=.+/
            end

            if !repo_control.empty?
              @yum.enable_extra_repo_control(repo_control.join(" "))
            else
              @yum.disable_extra_repo_control
            end
          else
            @yum.disable_extra_repo_control
          end
        end

        # Query the Yum cache for information about potential packages
        def query_yum_cache
          installed_versions = []
          candidate_versions = []

          package_name_array.each_with_index do |n, idx|
            pkg_name, eval_pkg_arch = parse_arch(n)

            # Defer to the arch property for the desired package architecture
            pkg_arch = safe_arch_array[idx] || eval_pkg_arch
            set_package_name(idx, pkg_name)
            set_package_arch(idx, pkg_arch)

            Chef::Log.debug("#{new_resource} checking yum info for #{yum_syntax(n, nil, pkg_arch)}")
            installed_versions << iv = @yum.installed_version(pkg_name, pkg_arch)
            candidate_versions << cv = @yum.candidate_version(pkg_name, pkg_arch)

            Chef::Log.debug("Found Yum package: #{pkg_name} installed version: #{iv || '(none)'} candidate version: #{cv || '(none)'}")
          end

          @installed_version = installed_versions.length > 1 ? installed_versions : installed_versions[0]
          @candidate_version = candidate_versions.length > 1 ? candidate_versions : candidate_versions[0]
        end

        # Query the provided source file for the package name and version
        def query_source_file
          Chef::Log.debug("#{new_resource} checking rpm status")
          shell_out_with_timeout!("rpm -qp --queryformat '%{NAME} %{VERSION}-%{RELEASE} %{ARCH}\n' #{new_resource.source}", timeout: Chef::Config[:yum_timeout]).stdout.each_line do |line|
            case line
            when /^(\S+)\s(\S+)\s(\S+)$/
              n = $1
              v = $2
              a = $3

              unless new_resource.package_name == n
                Chef::Log.debug("#{new_resource} updating package_name from #{new_resource.package_name} to #{n} (per #{new_resource.source})")
                new_resource.package_name(n)
              end

              unless new_resource.version == v
                Chef::Log.debug("#{new_resource} updating version from #{new_resource.version} to #{v} (per #{new_resource.source})")
                new_resource.version(v)
              end

              unless new_resource.arch == a
                Chef::Log.debug("#{new_resource} updating architecture from #{new_resource.arch} to #{a} (per #{new_resource.source})")
                new_resource.arch(a)
              end
            end
          end

          @installed_version = @yum.installed_version(new_resource.package_name, new_resource.arch)
          @candidate_version = new_resource.version
        end

        def yum_command(command)
          command = "#{yum_binary} #{command}"
          Chef::Log.debug("#{new_resource}: yum command: \"#{command}\"")
          status = shell_out_with_timeout(command, timeout: Chef::Config[:yum_timeout])

          # This is fun: rpm can encounter errors in the %post/%postun scripts which aren't
          # considered fatal - meaning the rpm is still successfully installed. These issue
          # cause yum to emit a non fatal warning but still exit(1). As there's currently no
          # way to suppress this behavior and an exit(1) will break a Chef run we make an
          # effort to trap these and re-run the same install command - it will either fail a
          # second time or succeed.
          #
          # A cleaner solution would have to be done in python and better hook into
          # yum/rpm to handle exceptions as we see fit.
          if status.exitstatus == 1
            status.stdout.each_line do |l|
              # rpm-4.4.2.3 lib/psm.c line 2182
              next unless l =~ /^error: %(post|postun)\(.*\) scriptlet failed, exit status \d+$/
              Chef::Log.warn("#{new_resource} caught non-fatal scriptlet issue: \"#{l}\". Can't trust yum exit status " \
                             "so running install again to verify.")
              status = shell_out_with_timeout(command, timeout: Chef::Config[:yum_timeout])
              break
            end
          end

          if status.exitstatus > 0
            command_output = "STDOUT: #{status.stdout}\nSTDERR: #{status.stderr}"
            raise Chef::Exceptions::Exec, "#{command} returned #{status.exitstatus}:\n#{command_output}"
          end
        end

        def install_remote_package(name, version)
          # Work around yum not exiting with an error if a package doesn't exist for CHEF-2062.
          all_avail = as_array(name).zip(as_array(version), safe_arch_array).any? do |n, v, a|
            @yum.version_available?(n, v, a)
          end

          method = log_method = nil
          methods = []
          if all_avail
            # More Yum fun:
            #
            # yum install of an old name+version will exit(1)
            # yum install of an old name+version+arch will exit(0) for some reason
            #
            # Some packages can be installed multiple times like the kernel
            as_array(name).zip(current_version_array, as_array(version), safe_arch_array).each do |n, cv, v, a|
              next if n.nil?

              method = "install"
              log_method = "installing"

              unless @yum.allow_multi_install.include?(n)
                if RPMVersion.parse(cv) > RPMVersion.parse(v)
                  # We allow downgrading only in the evenit of single-package
                  # rules where the user explicitly allowed it
                  if allow_downgrade
                    method = "downgrade"
                    log_method = "downgrading"
                  else
                    # we bail like yum when the package is older
                    raise Chef::Exceptions::Package, "Installed package #{yum_syntax(n, cv, a)} is newer " \
                      "than candidate package #{yum_syntax(n, v, a)}"
                  end
                end
              end
              # methods don't count for packages we won't be touching
              next if RPMVersion.parse(cv) == RPMVersion.parse(v)
              methods << method
            end

            # We could split this up into two commands if we wanted to, but
            # for now, just don't support this.
            if methods.uniq.length > 1
              raise Chef::Exceptions::Package, "Multipackage rule #{name} has a mix of upgrade and downgrade packages. Cannot proceed."
            end

            repos = []
            pkg_string_bits = []
            as_array(name).zip(current_version_array, as_array(version), safe_arch_array).each do |n, cv, v, a|
              next if n.nil?
              next if v == cv
              s = yum_syntax(n, v, a)
              repo = @yum.package_repository(n, v, a)
              repos << "#{s} from #{repo} repository"
              pkg_string_bits << s
            end
            pkg_string = pkg_string_bits.join(" ")
            Chef::Log.info("#{new_resource} #{log_method} #{repos.join(' ')}")
            yum_command("-d0 -e0 -y#{expand_options(new_resource.options)} #{method} #{pkg_string}")
          else
            raise Chef::Exceptions::Package, "Version #{version} of #{name} not found. Did you specify both version " \
              "and release? (version-release, e.g. 1.84-10.fc6)"
          end
        end

        # Allow for foo.x86_64 style package_name like yum uses in it's output
        def parse_arch(package_name)
          if package_name =~ /^(.*)\.(.*)$/
            new_package_name = $1
            new_arch = $2
            # foo.i386 and foo.beta1 are both valid package names or expressions of an arch.
            # Ensure we don't have an existing package matching package_name, then ensure we at
            # least have a match for the new_package+new_arch before we overwrite. If neither
            # then fall through to standard package handling.
            old_installed = @yum.installed_version(package_name)
            old_candidate = @yum.candidate_version(package_name)
            new_installed = @yum.installed_version(new_package_name, new_arch)
            new_candidate = @yum.candidate_version(new_package_name, new_arch)
            if (old_installed.nil? && old_candidate.nil?) && (new_installed || new_candidate)
              Chef::Log.debug("Parsed out arch #{new_arch}, new package name is #{new_package_name}")
              return new_package_name, new_arch
            end
          end
          [package_name, nil]
        end

        #
        # Dependency String Handling
        #

        # Iterate through the list of package_names given to us by the user and
        # see if any of them are in the depenency format ("foo >= 1.1"). Modify
        # the list of packages and versions to incorporate those values.
        def convert_dependency_strings_into_packages
          package_name_array.each_with_index do |n, index|
            next if @yum.package_available?(n)
            # If they aren't in the installed packages they could be a dependency.
            dep = parse_dependency(n, new_version_array[index])
            if dep
              if new_resource.package_name.is_a?(Array)
                new_resource.package_name(package_name_array - [n] + [dep.first])
                new_resource.version(new_version_array - [new_version_array[index]] + [dep.last]) if dep.last
              else
                new_resource.package_name(dep.first)
                new_resource.version(dep.last) if dep.last
              end
            end
          end
        end

        # If we don't have the package we could have been passed a 'whatprovides' feature
        #
        # eg: yum install "perl(Config)"
        #     yum install "mtr = 2:0.71-3.1"
        #     yum install "mtr > 2:0.71"
        #
        # We support resolving these out of the Provides data imported from yum-dump.py and
        # matching them up with an actual package so the standard resource handling can apply.
        #
        # There is currently no support for filename matching.
        #
        # Note: This was largely left alone during the multipackage refactor
        def parse_dependency(name, version)
          # Transform the package_name into a requirement

          # If we are passed a version or a version constraint we have to assume it's a requirement first. If it can't be
          # parsed only yum_require.name will be set and new_resource.version will be left intact
          require_string = if version
                             "#{name} #{version}"
                           else
                             # Transform the package_name into a requirement, might contain a version, could just be
                             # a match for virtual provides
                             name
                           end
          yum_require = RPMRequire.parse(require_string)
          # and gather all the packages that have a Provides feature satisfying the requirement.
          # It could be multiple be we can only manage one
          packages = @yum.packages_from_require(yum_require)

          if packages.empty?
            # Don't bother if we are just ensuring a package is removed - we don't need Provides data
            actions = Array(new_resource.action)
            unless actions.size == 1 && (actions[0] == :remove || actions[0] == :purge)
              Chef::Log.debug("#{new_resource} couldn't match #{new_resource.package_name} in " \
                            "installed Provides, loading available Provides - this may take a moment")
              @yum.reload_provides
              packages = @yum.packages_from_require(yum_require)
            end
          end

          unless packages.empty?
            new_package_name = packages.first.name
            new_package_version = packages.first.version.to_s
            debug_msg = "#{name}: Unable to match package '#{name}' but matched #{packages.size} "
            debug_msg << (packages.size == 1 ? "package" : "packages")
            debug_msg << ", selected '#{new_package_name}' version '#{new_package_version}'"
            Chef::Log.debug(debug_msg)

            # Ensure it's not the same package under a different architecture
            unique_names = []
            packages.each do |pkg|
              unique_names << "#{pkg.name}-#{pkg.version.evr}"
            end
            unique_names.uniq!

            if unique_names.size > 1
              Chef::Log.warn("#{new_resource} matched multiple Provides for #{new_resource.package_name} " \
                             "but we can only use the first match: #{new_package_name}. Please use a more " \
                             "specific version.")
            end

            if yum_require.version.to_s.nil?
              new_package_version = nil
            end

            [new_package_name, new_package_version]
          end
        end

        #
        # Misc Helpers
        #

        # Given an list of names and versions, generate the full yum syntax package name
        def full_package_name(name, version)
          as_array(name).zip(as_array(version), safe_arch_array).map do |n, v, a|
            yum_syntax(n, v, a)
          end
        end

        # Generate the yum syntax for the package
        def yum_syntax(name, version, arch)
          s = name
          s += "-#{version}" if version
          s += ".#{arch}" if arch
          s
        end

        # Set the package name correctly based on whether it is a String or Array
        def set_package_name(idx, name)
          if new_resource.package_name.is_a?(String)
            new_resource.package_name(name)
          else
            new_resource.package_name[idx] = name
          end
        end

        # Set the architecture correcly based on whether it is a String or Array
        def set_package_arch(idx, arch)
          if new_resource.package_name.is_a?(String)
            new_resource.arch(arch) unless arch.nil?
          else
            new_resource.arch ||= []
            new_resource.arch[idx] = arch
          end
        end

        # A cousin of package_name_array, return a list of the architectures
        # defined in the resource.
        def safe_arch_array
          if new_resource.arch.is_a?(Array)
            new_resource.arch
          elsif new_resource.arch.nil?
            package_name_array.map { nil }
          else
            [ new_resource.arch ]
          end
        end

        def flush_cache
          if new_resource.respond_to?("flush_cache")
            new_resource.flush_cache
          else
            { before: false, after: false }
          end
        end
      end
    end
  end
end