diff options
author | Lamont Granquist <lamont@scriptkiddie.org> | 2016-05-25 14:39:14 -0700 |
---|---|---|
committer | Lamont Granquist <lamont@scriptkiddie.org> | 2016-05-25 14:39:14 -0700 |
commit | 27c762a6606091f29d0a05289bec39c292fb5861 (patch) | |
tree | b37c8d9bef1738ea26caea7c7b7074e976a93969 | |
parent | b28096923b23fb18dfa397b52fc3decf9c161cbb (diff) | |
parent | fba56bf27a22ade011d7bdd2d566b392ed9f8b41 (diff) | |
download | chef-27c762a6606091f29d0a05289bec39c292fb5861.tar.gz |
Merge pull request #4901 from chef/lcg/yum-readability
break yum classes out into files
-rw-r--r-- | lib/chef/provider/package/yum.rb | 963 | ||||
-rw-r--r-- | lib/chef/provider/package/yum/rpm_utils.rb | 642 | ||||
-rw-r--r-- | lib/chef/provider/package/yum/yum-dump.py (renamed from lib/chef/provider/package/yum-dump.py) | 0 | ||||
-rw-r--r-- | lib/chef/provider/package/yum/yum_cache.rb | 376 | ||||
-rw-r--r-- | spec/unit/provider/package/yum/yum_cache_spec.rb | 27 |
5 files changed, 1049 insertions, 959 deletions
diff --git a/lib/chef/provider/package/yum.rb b/lib/chef/provider/package/yum.rb index 64f1b2043c..74d52946f7 100644 --- a/lib/chef/provider/package/yum.rb +++ b/lib/chef/provider/package/yum.rb @@ -18,10 +18,10 @@ require "chef/config" require "chef/provider/package" -require "chef/mixin/which" -require "chef/resource/package" -require "singleton" +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 @@ -31,961 +31,6 @@ class Chef provides :package, platform_family: %w{rhel fedora} provides :yum_package, os: "linux" - class RPMUtils - class << self - - # RPM::Version version_parse equivalent - def version_parse(evr) - return if evr.nil? - - epoch = nil - # assume this is a version - version = evr - release = nil - - lead = 0 - tail = evr.size - - if %r{^([\d]+):}.match(evr) # rubocop:disable Performance/RedundantMatch - epoch = $1.to_i - lead = $1.length + 1 - elsif evr[0].ord == ":".ord - epoch = 0 - lead = 1 - end - - if %r{:?.*-(.*)$}.match(evr) # rubocop:disable Performance/RedundantMatch - release = $1 - tail = evr.length - release.length - lead - 1 - - if release.empty? - release = nil - end - end - - version = evr[lead, tail] - if version.empty? - version = nil - end - - [ epoch, version, release ] - end - - # verify - def isalnum(x) - isalpha(x) || isdigit(x) - end - - def isalpha(x) - v = x.ord - (v >= 65 && v <= 90) || (v >= 97 && v <= 122) - end - - def isdigit(x) - v = x.ord - v >= 48 && v <= 57 - end - - # based on the reference spec in lib/rpmvercmp.c in rpm 4.9.0 - def rpmvercmp(x, y) - # easy! :) - return 0 if x == y - - if x.nil? - x = "" - end - - if y.nil? - y = "" - end - - # not so easy :( - # - # takes 2 strings like - # - # x = "1.20.b18.el5" - # y = "1.20.b17.el5" - # - # breaks into purely alpha and numeric segments and compares them using - # some rules - # - # * 10 > 1 - # * 1 > a - # * z > a - # * Z > A - # * z > Z - # * leading zeros are ignored - # * separators (periods, commas) are ignored - # * "1.20.b18.el5.extrastuff" > "1.20.b18.el5" - - x_pos = 0 # overall string element reference position - x_pos_max = x.length - 1 # number of elements in string, starting from 0 - x_seg_pos = 0 # segment string element reference position - x_comp = nil # segment to compare - - y_pos = 0 - y_seg_pos = 0 - y_pos_max = y.length - 1 - y_comp = nil - - while x_pos <= x_pos_max && y_pos <= y_pos_max - # first we skip over anything non alphanumeric - while (x_pos <= x_pos_max) && (isalnum(x[x_pos]) == false) - x_pos += 1 # +1 over pos_max if end of string - end - while (y_pos <= y_pos_max) && (isalnum(y[y_pos]) == false) - y_pos += 1 - end - - # if we hit the end of either we are done matching segments - if (x_pos == x_pos_max + 1) || (y_pos == y_pos_max + 1) - break - end - - # we are now at the start of a alpha or numeric segment - x_seg_pos = x_pos - y_seg_pos = y_pos - - # grab segment so we can compare them - if isdigit(x[x_seg_pos].ord) - x_seg_is_num = true - - # already know it's a digit - x_seg_pos += 1 - - # gather up our digits - while (x_seg_pos <= x_pos_max) && isdigit(x[x_seg_pos]) - x_seg_pos += 1 - end - # copy the segment but not the unmatched character that x_seg_pos will - # refer to - x_comp = x[x_pos, x_seg_pos - x_pos] - - while (y_seg_pos <= y_pos_max) && isdigit(y[y_seg_pos]) - y_seg_pos += 1 - end - y_comp = y[y_pos, y_seg_pos - y_pos] - else - # we are comparing strings - x_seg_is_num = false - - while (x_seg_pos <= x_pos_max) && isalpha(x[x_seg_pos]) - x_seg_pos += 1 - end - x_comp = x[x_pos, x_seg_pos - x_pos] - - while (y_seg_pos <= y_pos_max) && isalpha(y[y_seg_pos]) - y_seg_pos += 1 - end - y_comp = y[y_pos, y_seg_pos - y_pos] - end - - # if y_seg_pos didn't advance in the above loop it means the segments are - # different types - if y_pos == y_seg_pos - # numbers always win over letters - return x_seg_is_num ? 1 : -1 - end - - # move the ball forward before we mess with the segments - x_pos += x_comp.length # +1 over pos_max if end of string - y_pos += y_comp.length - - # we are comparing numbers - simply convert them - if x_seg_is_num - x_comp = x_comp.to_i - y_comp = y_comp.to_i - end - - # compares ints or strings - # don't return if equal - try the next segment - if x_comp > y_comp - return 1 - elsif x_comp < y_comp - return -1 - end - - # if we've reached here than the segments are the same - try again - end - - # we must have reached the end of one or both of the strings and they - # matched up until this point - - # segments matched completely but the segment separators were different - - # rpm reference code treats these as equal. - if (x_pos == x_pos_max + 1) && (y_pos == y_pos_max + 1) - return 0 - end - - # the most unprocessed characters left wins - if (x_pos_max - x_pos) > (y_pos_max - y_pos) - return 1 - else - return -1 - end - end - - end # self - end # RPMUtils - - class RPMVersion - include Comparable - - def initialize(*args) - if args.size == 1 - @e, @v, @r = RPMUtils.version_parse(args[0]) - elsif args.size == 3 - @e = args[0].to_i - @v = args[1] - @r = args[2] - else - raise ArgumentError, "Expecting either 'epoch-version-release' or 'epoch, " + - "version, release'" - end - end - attr_reader :e, :v, :r - alias :epoch :e - alias :version :v - alias :release :r - - def self.parse(*args) - self.new(*args) - end - - def <=>(y) - compare_versions(y) - end - - def compare(y) - compare_versions(y, false) - end - - def partial_compare(y) - compare_versions(y, true) - end - - # RPM::Version rpm_version_to_s equivalent - def to_s - if @r.nil? - @v - else - "#{@v}-#{@r}" - end - end - - def evr - "#{@e}:#{@v}-#{@r}" - end - - private - - # Rough RPM::Version rpm_version_cmp equivalent - except much slower :) - # - # partial lets epoch and version segment equality be good enough to return equal, eg: - # - # 2:1.2-1 == 2:1.2 - # 2:1.2-1 == 2: - # - def compare_versions(y, partial = false) - x = self - - # compare epoch - if (x.e.nil? == false && x.e > 0) && y.e.nil? - return 1 - elsif x.e.nil? && (y.e.nil? == false && y.e > 0) - return -1 - elsif x.e.nil? == false && y.e.nil? == false - if x.e < y.e - return -1 - elsif x.e > y.e - return 1 - end - end - - # compare version - if partial && (x.v.nil? || y.v.nil?) - return 0 - elsif x.v.nil? == false && y.v.nil? - return 1 - elsif x.v.nil? && y.v.nil? == false - return -1 - elsif x.v.nil? == false && y.v.nil? == false - cmp = RPMUtils.rpmvercmp(x.v, y.v) - return cmp if cmp != 0 - end - - # compare release - if partial && (x.r.nil? || y.r.nil?) - return 0 - elsif x.r.nil? == false && y.r.nil? - return 1 - elsif x.r.nil? && y.r.nil? == false - return -1 - elsif x.r.nil? == false && y.r.nil? == false - cmp = RPMUtils.rpmvercmp(x.r, y.r) - return cmp - end - - return 0 - end - end - - class RPMPackage - include Comparable - - def initialize(*args) - if args.size == 4 - @n = args[0] - @version = RPMVersion.new(args[1]) - @a = args[2] - @provides = args[3] - elsif args.size == 6 - @n = args[0] - e = args[1].to_i - v = args[2] - r = args[3] - @version = RPMVersion.new(e, v, r) - @a = args[4] - @provides = args[5] - else - raise ArgumentError, "Expecting either 'name, epoch-version-release, arch, provides' " + - "or 'name, epoch, version, release, arch, provides'" - end - - # We always have one, ourselves! - if @provides.empty? - @provides = [ RPMProvide.new(@n, @version.evr, :==) ] - end - end - attr_reader :n, :a, :version, :provides - alias :name :n - alias :arch :a - - def <=>(y) - compare(y) - end - - def compare(y) - x = self - - # easy! :) - return 0 if x.nevra == y.nevra - - # compare name - if x.n.nil? == false && y.n.nil? - return 1 - elsif x.n.nil? && y.n.nil? == false - return -1 - elsif x.n.nil? == false && y.n.nil? == false - if x.n < y.n - return -1 - elsif x.n > y.n - return 1 - end - end - - # compare version - if x.version > y.version - return 1 - elsif x.version < y.version - return -1 - end - - # compare arch - if x.a.nil? == false && y.a.nil? - return 1 - elsif x.a.nil? && y.a.nil? == false - return -1 - elsif x.a.nil? == false && y.a.nil? == false - if x.a < y.a - return -1 - elsif x.a > y.a - return 1 - end - end - - return 0 - end - - def to_s - nevra - end - - def nevra - "#{@n}-#{@version.evr}.#{@a}" - end - end - - # Simple implementation from rpm and ruby-rpm reference code - class RPMDependency - def initialize(*args) - if args.size == 3 - @name = args[0] - @version = RPMVersion.new(args[1]) - # Our requirement to other dependencies - @flag = args[2] || :== - elsif args.size == 5 - @name = args[0] - e = args[1].to_i - v = args[2] - r = args[3] - @version = RPMVersion.new(e, v, r) - @flag = args[4] || :== - else - raise ArgumentError, "Expecting either 'name, epoch-version-release, flag' or " + - "'name, epoch, version, release, flag'" - end - end - attr_reader :name, :version, :flag - - # Parses 2 forms: - # - # "mtr >= 2:0.71-3.0" - # "mta" - def self.parse(string) - if %r{^(\S+)\s+(>|>=|=|==|<=|<)\s+(\S+)$}.match(string) # rubocop:disable Performance/RedundantMatch - name = $1 - if $2 == "=" - flag = :== - else - flag = :"#{$2}" - end - version = $3 - - return self.new(name, version, flag) - else - name = string - return self.new(name, nil, nil) - end - end - - # Test if another RPMDependency satisfies our requirements - def satisfy?(y) - unless y.kind_of?(RPMDependency) - raise ArgumentError, "Expecting an RPMDependency object" - end - - x = self - - # Easy! - if x.name != y.name - return false - end - - # Partial compare - # - # eg: x.version 2.3 == y.version 2.3-1 - sense = x.version.partial_compare(y.version) - - # Thanks to rpmdsCompare() rpmds.c - if (sense < 0) && ((x.flag == :> || x.flag == :>=) || (y.flag == :<= || y.flag == :<)) - return true - elsif (sense > 0) && ((x.flag == :< || x.flag == :<=) || (y.flag == :>= || y.flag == :>)) - return true - elsif sense == 0 && ( - ((x.flag == :== || x.flag == :<= || x.flag == :>=) && (y.flag == :== || y.flag == :<= || y.flag == :>=)) || - (x.flag == :< && y.flag == :<) || - (x.flag == :> && y.flag == :>) - ) - return true - end - - return false - end - end - - class RPMProvide < RPMDependency; end - class RPMRequire < RPMDependency; end - - class RPMDbPackage < RPMPackage - # <rpm parts>, installed, available - def initialize(*args) - @repoid = args.pop - # state - @available = args.pop - @installed = args.pop - super(*args) - end - attr_reader :repoid, :available, :installed - end - - # Simple storage for RPMPackage objects - keeps them unique and sorted - class RPMDb - def initialize - # package name => [ RPMPackage, RPMPackage ] of different versions - @rpms = Hash.new - # package nevra => RPMPackage for lookups - @index = Hash.new - # provide name (aka feature) => [RPMPackage, RPMPackage] each providing this feature - @provides = Hash.new - # RPMPackages listed as available - @available = Set.new - # RPMPackages listed as installed - @installed = Set.new - end - - def [](package_name) - self.lookup(package_name) - end - - # Lookup package_name and return a descending array of package objects - def lookup(package_name) - pkgs = @rpms[package_name] - if pkgs - return pkgs.sort.reverse - else - return nil - end - end - - def lookup_provides(provide_name) - @provides[provide_name] - end - - # Using the package name as a key, and nevra for an index, keep a unique list of packages. - # The available/installed state can be overwritten for existing packages. - def push(*args) - args.flatten.each do |new_rpm| - unless new_rpm.kind_of?(RPMDbPackage) - raise ArgumentError, "Expecting an RPMDbPackage object" - end - - @rpms[new_rpm.n] ||= Array.new - - # we may already have this one, like when the installed list is refreshed - idx = @index[new_rpm.nevra] - if idx - # grab the existing package if it's not - curr_rpm = idx - else - @rpms[new_rpm.n] << new_rpm - - new_rpm.provides.each do |provide| - @provides[provide.name] ||= Array.new - @provides[provide.name] << new_rpm - end - - curr_rpm = new_rpm - end - - # Track the nevra -> RPMPackage association to avoid having to compare versions - # with @rpms[new_rpm.n] on the next round - @index[new_rpm.nevra] = curr_rpm - - # these are overwritten for existing packages - if new_rpm.available - @available << curr_rpm - end - if new_rpm.installed - @installed << curr_rpm - end - end - end - - def <<(*args) - self.push(args) - end - - def clear - @rpms.clear - @index.clear - @provides.clear - clear_available - clear_installed - end - - def clear_available - @available.clear - end - - def clear_installed - @installed.clear - end - - def size - @rpms.size - end - alias :length :size - - def available_size - @available.size - end - - def installed_size - @installed.size - end - - def available?(package) - @available.include?(package) - end - - def installed?(package) - @installed.include?(package) - end - - def whatprovides(rpmdep) - unless rpmdep.kind_of?(RPMDependency) - raise ArgumentError, "Expecting an RPMDependency object" - end - - what = [] - - packages = lookup_provides(rpmdep.name) - if packages - packages.each do |pkg| - pkg.provides.each do |provide| - if provide.satisfy?(rpmdep) - what << pkg - end - end - end - end - - return what - end - end - - # Cache for our installed and available packages, pulled in from yum-dump.py - class YumCache - include Chef::Mixin::Which - include Chef::Mixin::ShellOut - include Singleton - - attr_accessor :yum_binary - - def initialize - @rpmdb = RPMDb.new - - # Next time @rpmdb is accessed: - # :all - Trigger a run of "yum-dump.py --options --installed-provides", updates - # yum's cache and parses options from /etc/yum.conf. Pulls in Provides - # dependency data for installed packages only - this data is slow to - # gather. - # :provides - Same as :all but pulls in Provides data for available packages as well. - # Used as a last resort when we can't find a Provides match. - # :installed - Trigger a run of "yum-dump.py --installed", only reads the local rpm - # db. Used between client runs for a quick refresh. - # :none - Do nothing, a call to one of the reload methods is required. - @next_refresh = :all - - @allow_multi_install = [] - - @extra_repo_control = nil - - # these are for subsequent runs if we are on an interval - Chef::Client.when_run_starts do - YumCache.instance.reload - end - end - - attr_reader :extra_repo_control - - # Cache management - # - - def refresh - case @next_refresh - when :none - return nil - when :installed - reset_installed - # fast - opts = " --installed" - when :all - reset - # medium - opts = " --options --installed-provides" - when :provides - reset - # slow! - opts = " --options --all-provides" - else - raise ArgumentError, "Unexpected value in next_refresh: #{@next_refresh}" - end - - if @extra_repo_control - opts << " #{@extra_repo_control}" - end - - opts << " --yum-lock-timeout #{Chef::Config[:yum_lock_timeout]}" - - one_line = false - error = nil - - helper = ::File.join(::File.dirname(__FILE__), "yum-dump.py") - status = nil - - begin - status = shell_out!("#{python_bin} #{helper}#{opts}", :timeout => Chef::Config[:yum_timeout]) - status.stdout.each_line do |line| - one_line = true - - line.chomp! - if line =~ %r{\[option (.*)\] (.*)} - if $1 == "installonlypkgs" - @allow_multi_install = $2.split - else - raise Chef::Exceptions::Package, "Strange, unknown option line '#{line}' from yum-dump.py" - end - next - end - - if line =~ %r{^(\S+) ([0-9]+) (\S+) (\S+) (\S+) \[(.*)\] ([i,a,r]) (\S+)$} - name = $1 - epoch = $2 - version = $3 - release = $4 - arch = $5 - provides = parse_provides($6) - type = $7 - repoid = $8 - else - Chef::Log.warn("Problem parsing line '#{line}' from yum-dump.py! " + - "Please check your yum configuration.") - next - end - - case type - when "i" - # if yum-dump was called with --installed this may not be true, but it's okay - # since we don't touch the @available Set in reload_installed - available = false - installed = true - when "a" - available = true - installed = false - when "r" - available = true - installed = true - end - - pkg = RPMDbPackage.new(name, epoch, version, release, arch, provides, installed, available, repoid) - @rpmdb << pkg - end - - error = status.stderr - rescue Mixlib::ShellOut::CommandTimeout => e - Chef::Log.error("#{helper} exceeded timeout #{Chef::Config[:yum_timeout]}") - raise(e) - end - - if status.exitstatus != 0 - raise Chef::Exceptions::Package, "Yum failed - #{status.inspect} - returns: #{error}" - else - unless one_line - Chef::Log.warn("Odd, no output from yum-dump.py. Please check " + - "your yum configuration.") - end - end - - # A reload method must be called before the cache is altered - @next_refresh = :none - end - - def python_bin - yum_executable = which(yum_binary) - if yum_executable && shabang?(yum_executable) - shabang_or_fallback(extract_interpreter(yum_executable)) - else - Chef::Log.warn("Yum executable not found or doesn't start with #!. Using default python.") - "/usr/bin/python" - end - rescue StandardError => e - Chef::Log.warn("An error occurred attempting to determine correct python executable. Using default.") - Chef::Log.debug(e) - "/usr/bin/python" - end - - def extract_interpreter(file) - ::File.open(file, "r", &:readline)[2..-1].strip - end - - # dnf based systems have a yum shim that has /bin/bash as the interpreter. Don't use this. - def shabang_or_fallback(interpreter) - if interpreter == "/bin/bash" - Chef::Log.warn("Yum executable interpreter is /bin/bash. Falling back to default python.") - "/usr/bin/python" - else - interpreter - end - end - - def shabang?(file) - ::File.open(file, "r") do |f| - f.read(2) == '#!' - end - rescue Errno::ENOENT - false - end - - def reload - @next_refresh = :all - end - - def reload_installed - @next_refresh = :installed - end - - def reload_provides - @next_refresh = :provides - end - - def reset - @rpmdb.clear - end - - def reset_installed - @rpmdb.clear_installed - end - - # Querying the cache - # - - # Check for package by name or name+arch - def package_available?(package_name) - refresh - - if @rpmdb.lookup(package_name) - return true - else - if package_name =~ %r{^(.*)\.(.*)$} - pkg_name = $1 - pkg_arch = $2 - - if matches = @rpmdb.lookup(pkg_name) - matches.each do |m| - return true if m.arch == pkg_arch - end - end - end - end - - return false - end - - # Returns a array of packages satisfying an RPMDependency - def packages_from_require(rpmdep) - refresh - @rpmdb.whatprovides(rpmdep) - end - - # Check if a package-version.arch is available to install - def version_available?(package_name, desired_version, arch = nil) - version(package_name, arch, true, false) do |v| - return true if desired_version == v - end - - return false - end - - # Return the source repository for a package-version.arch - def package_repository(package_name, desired_version, arch = nil) - package(package_name, arch, true, false) do |pkg| - return pkg.repoid if desired_version == pkg.version.to_s - end - - return nil - end - - # Return the latest available version for a package.arch - def available_version(package_name, arch = nil) - version(package_name, arch, true, false) - end - alias :candidate_version :available_version - - # Return the currently installed version for a package.arch - def installed_version(package_name, arch = nil) - version(package_name, arch, false, true) - end - - # Return an array of packages allowed to be installed multiple times, such as the kernel - def allow_multi_install - refresh - @allow_multi_install - end - - def enable_extra_repo_control(arg) - # Don't touch cache if it's the same repos as the last load - unless @extra_repo_control == arg - @extra_repo_control = arg - reload - end - end - - def disable_extra_repo_control - # Only force reload when set - if @extra_repo_control - @extra_repo_control = nil - reload - end - end - - private - - def version(package_name, arch = nil, is_available = false, is_installed = false) - package(package_name, arch, is_available, is_installed) do |pkg| - if block_given? - yield pkg.version.to_s - else - # first match is latest version - return pkg.version.to_s - end - end - - if block_given? - return self - else - return nil - end - end - - def package(package_name, arch = nil, is_available = false, is_installed = false) - refresh - packages = @rpmdb[package_name] - if packages - packages.each do |pkg| - if is_available - next unless @rpmdb.available?(pkg) - end - if is_installed - next unless @rpmdb.installed?(pkg) - end - if arch - next unless pkg.arch == arch - end - - if block_given? - yield pkg - else - # first match is latest version - return pkg - end - end - end - - if block_given? - return self - else - return nil - end - end - - # Parse provides from yum-dump.py output - def parse_provides(string) - ret = [] - # ['atk = 1.12.2-1.fc6', 'libatk-1.0.so.0'] - string.split(", ").each do |seg| - # 'atk = 1.12.2-1.fc6' - if seg =~ %r{^'(.*)'$} - ret << RPMProvide.parse($1) - end - end - - return ret - end - - end # YumCache - include Chef::Mixin::GetSourceFromPackage def initialize(new_resource, run_context) @@ -1126,7 +171,7 @@ class Chef end end - @current_resource = Chef::Resource::Package.new(@new_resource.name) + @current_resource = Chef::Resource::YumPackage.new(@new_resource.name) @current_resource.package_name(@new_resource.package_name) installed_version = [] diff --git a/lib/chef/provider/package/yum/rpm_utils.rb b/lib/chef/provider/package/yum/rpm_utils.rb new file mode 100644 index 0000000000..a748c664a9 --- /dev/null +++ b/lib/chef/provider/package/yum/rpm_utils.rb @@ -0,0 +1,642 @@ + +# Author:: Adam Jacob (<adam@chef.io>) +# Copyright:: Copyright 2008-2016, 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/provider/package" + +class Chef + class Provider + class Package + class Yum < Chef::Provider::Package + class RPMUtils + class << self + + # RPM::Version version_parse equivalent + def version_parse(evr) + return if evr.nil? + + epoch = nil + # assume this is a version + version = evr + release = nil + + lead = 0 + tail = evr.size + + if %r{^([\d]+):}.match(evr) # rubocop:disable Performance/RedundantMatch + epoch = $1.to_i + lead = $1.length + 1 + elsif evr[0].ord == ":".ord + epoch = 0 + lead = 1 + end + + if %r{:?.*-(.*)$}.match(evr) # rubocop:disable Performance/RedundantMatch + release = $1 + tail = evr.length - release.length - lead - 1 + + if release.empty? + release = nil + end + end + + version = evr[lead, tail] + if version.empty? + version = nil + end + + [ epoch, version, release ] + end + + # verify + def isalnum(x) + isalpha(x) || isdigit(x) + end + + def isalpha(x) + v = x.ord + (v >= 65 && v <= 90) || (v >= 97 && v <= 122) + end + + def isdigit(x) + v = x.ord + v >= 48 && v <= 57 + end + + # based on the reference spec in lib/rpmvercmp.c in rpm 4.9.0 + def rpmvercmp(x, y) + # easy! :) + return 0 if x == y + + if x.nil? + x = "" + end + + if y.nil? + y = "" + end + + # not so easy :( + # + # takes 2 strings like + # + # x = "1.20.b18.el5" + # y = "1.20.b17.el5" + # + # breaks into purely alpha and numeric segments and compares them using + # some rules + # + # * 10 > 1 + # * 1 > a + # * z > a + # * Z > A + # * z > Z + # * leading zeros are ignored + # * separators (periods, commas) are ignored + # * "1.20.b18.el5.extrastuff" > "1.20.b18.el5" + + x_pos = 0 # overall string element reference position + x_pos_max = x.length - 1 # number of elements in string, starting from 0 + x_seg_pos = 0 # segment string element reference position + x_comp = nil # segment to compare + + y_pos = 0 + y_seg_pos = 0 + y_pos_max = y.length - 1 + y_comp = nil + + while x_pos <= x_pos_max && y_pos <= y_pos_max + # first we skip over anything non alphanumeric + while (x_pos <= x_pos_max) && (isalnum(x[x_pos]) == false) + x_pos += 1 # +1 over pos_max if end of string + end + while (y_pos <= y_pos_max) && (isalnum(y[y_pos]) == false) + y_pos += 1 + end + + # if we hit the end of either we are done matching segments + if (x_pos == x_pos_max + 1) || (y_pos == y_pos_max + 1) + break + end + + # we are now at the start of a alpha or numeric segment + x_seg_pos = x_pos + y_seg_pos = y_pos + + # grab segment so we can compare them + if isdigit(x[x_seg_pos].ord) + x_seg_is_num = true + + # already know it's a digit + x_seg_pos += 1 + + # gather up our digits + while (x_seg_pos <= x_pos_max) && isdigit(x[x_seg_pos]) + x_seg_pos += 1 + end + # copy the segment but not the unmatched character that x_seg_pos will + # refer to + x_comp = x[x_pos, x_seg_pos - x_pos] + + while (y_seg_pos <= y_pos_max) && isdigit(y[y_seg_pos]) + y_seg_pos += 1 + end + y_comp = y[y_pos, y_seg_pos - y_pos] + else + # we are comparing strings + x_seg_is_num = false + + while (x_seg_pos <= x_pos_max) && isalpha(x[x_seg_pos]) + x_seg_pos += 1 + end + x_comp = x[x_pos, x_seg_pos - x_pos] + + while (y_seg_pos <= y_pos_max) && isalpha(y[y_seg_pos]) + y_seg_pos += 1 + end + y_comp = y[y_pos, y_seg_pos - y_pos] + end + + # if y_seg_pos didn't advance in the above loop it means the segments are + # different types + if y_pos == y_seg_pos + # numbers always win over letters + return x_seg_is_num ? 1 : -1 + end + + # move the ball forward before we mess with the segments + x_pos += x_comp.length # +1 over pos_max if end of string + y_pos += y_comp.length + + # we are comparing numbers - simply convert them + if x_seg_is_num + x_comp = x_comp.to_i + y_comp = y_comp.to_i + end + + # compares ints or strings + # don't return if equal - try the next segment + if x_comp > y_comp + return 1 + elsif x_comp < y_comp + return -1 + end + + # if we've reached here than the segments are the same - try again + end + + # we must have reached the end of one or both of the strings and they + # matched up until this point + + # segments matched completely but the segment separators were different - + # rpm reference code treats these as equal. + if (x_pos == x_pos_max + 1) && (y_pos == y_pos_max + 1) + return 0 + end + + # the most unprocessed characters left wins + if (x_pos_max - x_pos) > (y_pos_max - y_pos) + return 1 + else + return -1 + end + end + + end # self + end # RPMUtils + + class RPMVersion + include Comparable + + def initialize(*args) + if args.size == 1 + @e, @v, @r = RPMUtils.version_parse(args[0]) + elsif args.size == 3 + @e = args[0].to_i + @v = args[1] + @r = args[2] + else + raise ArgumentError, "Expecting either 'epoch-version-release' or 'epoch, " + + "version, release'" + end + end + attr_reader :e, :v, :r + alias :epoch :e + alias :version :v + alias :release :r + + def self.parse(*args) + self.new(*args) + end + + def <=>(y) + compare_versions(y) + end + + def compare(y) + compare_versions(y, false) + end + + def partial_compare(y) + compare_versions(y, true) + end + + # RPM::Version rpm_version_to_s equivalent + def to_s + if @r.nil? + @v + else + "#{@v}-#{@r}" + end + end + + def evr + "#{@e}:#{@v}-#{@r}" + end + + private + + # Rough RPM::Version rpm_version_cmp equivalent - except much slower :) + # + # partial lets epoch and version segment equality be good enough to return equal, eg: + # + # 2:1.2-1 == 2:1.2 + # 2:1.2-1 == 2: + # + def compare_versions(y, partial = false) + x = self + + # compare epoch + if (x.e.nil? == false && x.e > 0) && y.e.nil? + return 1 + elsif x.e.nil? && (y.e.nil? == false && y.e > 0) + return -1 + elsif x.e.nil? == false && y.e.nil? == false + if x.e < y.e + return -1 + elsif x.e > y.e + return 1 + end + end + + # compare version + if partial && (x.v.nil? || y.v.nil?) + return 0 + elsif x.v.nil? == false && y.v.nil? + return 1 + elsif x.v.nil? && y.v.nil? == false + return -1 + elsif x.v.nil? == false && y.v.nil? == false + cmp = RPMUtils.rpmvercmp(x.v, y.v) + return cmp if cmp != 0 + end + + # compare release + if partial && (x.r.nil? || y.r.nil?) + return 0 + elsif x.r.nil? == false && y.r.nil? + return 1 + elsif x.r.nil? && y.r.nil? == false + return -1 + elsif x.r.nil? == false && y.r.nil? == false + cmp = RPMUtils.rpmvercmp(x.r, y.r) + return cmp + end + + return 0 + end + end + + class RPMPackage + include Comparable + + def initialize(*args) + if args.size == 4 + @n = args[0] + @version = RPMVersion.new(args[1]) + @a = args[2] + @provides = args[3] + elsif args.size == 6 + @n = args[0] + e = args[1].to_i + v = args[2] + r = args[3] + @version = RPMVersion.new(e, v, r) + @a = args[4] + @provides = args[5] + else + raise ArgumentError, "Expecting either 'name, epoch-version-release, arch, provides' " + + "or 'name, epoch, version, release, arch, provides'" + end + + # We always have one, ourselves! + if @provides.empty? + @provides = [ RPMProvide.new(@n, @version.evr, :==) ] + end + end + attr_reader :n, :a, :version, :provides + alias :name :n + alias :arch :a + + def <=>(y) + compare(y) + end + + def compare(y) + x = self + + # easy! :) + return 0 if x.nevra == y.nevra + + # compare name + if x.n.nil? == false && y.n.nil? + return 1 + elsif x.n.nil? && y.n.nil? == false + return -1 + elsif x.n.nil? == false && y.n.nil? == false + if x.n < y.n + return -1 + elsif x.n > y.n + return 1 + end + end + + # compare version + if x.version > y.version + return 1 + elsif x.version < y.version + return -1 + end + + # compare arch + if x.a.nil? == false && y.a.nil? + return 1 + elsif x.a.nil? && y.a.nil? == false + return -1 + elsif x.a.nil? == false && y.a.nil? == false + if x.a < y.a + return -1 + elsif x.a > y.a + return 1 + end + end + + return 0 + end + + def to_s + nevra + end + + def nevra + "#{@n}-#{@version.evr}.#{@a}" + end + end + + # Simple implementation from rpm and ruby-rpm reference code + class RPMDependency + def initialize(*args) + if args.size == 3 + @name = args[0] + @version = RPMVersion.new(args[1]) + # Our requirement to other dependencies + @flag = args[2] || :== + elsif args.size == 5 + @name = args[0] + e = args[1].to_i + v = args[2] + r = args[3] + @version = RPMVersion.new(e, v, r) + @flag = args[4] || :== + else + raise ArgumentError, "Expecting either 'name, epoch-version-release, flag' or " + + "'name, epoch, version, release, flag'" + end + end + attr_reader :name, :version, :flag + + # Parses 2 forms: + # + # "mtr >= 2:0.71-3.0" + # "mta" + def self.parse(string) + if %r{^(\S+)\s+(>|>=|=|==|<=|<)\s+(\S+)$}.match(string) # rubocop:disable Performance/RedundantMatch + name = $1 + if $2 == "=" + flag = :== + else + flag = :"#{$2}" + end + version = $3 + + return self.new(name, version, flag) + else + name = string + return self.new(name, nil, nil) + end + end + + # Test if another RPMDependency satisfies our requirements + def satisfy?(y) + unless y.kind_of?(RPMDependency) + raise ArgumentError, "Expecting an RPMDependency object" + end + + x = self + + # Easy! + if x.name != y.name + return false + end + + # Partial compare + # + # eg: x.version 2.3 == y.version 2.3-1 + sense = x.version.partial_compare(y.version) + + # Thanks to rpmdsCompare() rpmds.c + if (sense < 0) && ((x.flag == :> || x.flag == :>=) || (y.flag == :<= || y.flag == :<)) + return true + elsif (sense > 0) && ((x.flag == :< || x.flag == :<=) || (y.flag == :>= || y.flag == :>)) + return true + elsif sense == 0 && ( + ((x.flag == :== || x.flag == :<= || x.flag == :>=) && (y.flag == :== || y.flag == :<= || y.flag == :>=)) || + (x.flag == :< && y.flag == :<) || + (x.flag == :> && y.flag == :>) + ) + return true + end + + return false + end + end + + class RPMProvide < RPMDependency; end + class RPMRequire < RPMDependency; end + + class RPMDbPackage < RPMPackage + # <rpm parts>, installed, available + def initialize(*args) + @repoid = args.pop + # state + @available = args.pop + @installed = args.pop + super(*args) + end + attr_reader :repoid, :available, :installed + end + + # Simple storage for RPMPackage objects - keeps them unique and sorted + class RPMDb + def initialize + # package name => [ RPMPackage, RPMPackage ] of different versions + @rpms = Hash.new + # package nevra => RPMPackage for lookups + @index = Hash.new + # provide name (aka feature) => [RPMPackage, RPMPackage] each providing this feature + @provides = Hash.new + # RPMPackages listed as available + @available = Set.new + # RPMPackages listed as installed + @installed = Set.new + end + + def [](package_name) + self.lookup(package_name) + end + + # Lookup package_name and return a descending array of package objects + def lookup(package_name) + pkgs = @rpms[package_name] + if pkgs + return pkgs.sort.reverse + else + return nil + end + end + + def lookup_provides(provide_name) + @provides[provide_name] + end + + # Using the package name as a key, and nevra for an index, keep a unique list of packages. + # The available/installed state can be overwritten for existing packages. + def push(*args) + args.flatten.each do |new_rpm| + unless new_rpm.kind_of?(RPMDbPackage) + raise ArgumentError, "Expecting an RPMDbPackage object" + end + + @rpms[new_rpm.n] ||= Array.new + + # we may already have this one, like when the installed list is refreshed + idx = @index[new_rpm.nevra] + if idx + # grab the existing package if it's not + curr_rpm = idx + else + @rpms[new_rpm.n] << new_rpm + + new_rpm.provides.each do |provide| + @provides[provide.name] ||= Array.new + @provides[provide.name] << new_rpm + end + + curr_rpm = new_rpm + end + + # Track the nevra -> RPMPackage association to avoid having to compare versions + # with @rpms[new_rpm.n] on the next round + @index[new_rpm.nevra] = curr_rpm + + # these are overwritten for existing packages + if new_rpm.available + @available << curr_rpm + end + if new_rpm.installed + @installed << curr_rpm + end + end + end + + def <<(*args) + self.push(args) + end + + def clear + @rpms.clear + @index.clear + @provides.clear + clear_available + clear_installed + end + + def clear_available + @available.clear + end + + def clear_installed + @installed.clear + end + + def size + @rpms.size + end + alias :length :size + + def available_size + @available.size + end + + def installed_size + @installed.size + end + + def available?(package) + @available.include?(package) + end + + def installed?(package) + @installed.include?(package) + end + + def whatprovides(rpmdep) + unless rpmdep.kind_of?(RPMDependency) + raise ArgumentError, "Expecting an RPMDependency object" + end + + what = [] + + packages = lookup_provides(rpmdep.name) + if packages + packages.each do |pkg| + pkg.provides.each do |provide| + if provide.satisfy?(rpmdep) + what << pkg + end + end + end + end + + return what + end + end + + end + end + end +end diff --git a/lib/chef/provider/package/yum-dump.py b/lib/chef/provider/package/yum/yum-dump.py index 6183460195..6183460195 100644 --- a/lib/chef/provider/package/yum-dump.py +++ b/lib/chef/provider/package/yum/yum-dump.py diff --git a/lib/chef/provider/package/yum/yum_cache.rb b/lib/chef/provider/package/yum/yum_cache.rb new file mode 100644 index 0000000000..fb25a91c8c --- /dev/null +++ b/lib/chef/provider/package/yum/yum_cache.rb @@ -0,0 +1,376 @@ + +# Author:: Adam Jacob (<adam@chef.io>) +# Copyright:: Copyright 2008-2016, 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/mixin/which" +require "chef/mixin/shell_out" +require "singleton" +require "chef/provider/package/yum/rpm_utils" + +class Chef + class Provider + class Package + class Yum < Chef::Provider::Package + # Cache for our installed and available packages, pulled in from yum-dump.py + class YumCache + include Chef::Mixin::Which + include Chef::Mixin::ShellOut + include Singleton + + attr_accessor :yum_binary + + def initialize + @rpmdb = RPMDb.new + + # Next time @rpmdb is accessed: + # :all - Trigger a run of "yum-dump.py --options --installed-provides", updates + # yum's cache and parses options from /etc/yum.conf. Pulls in Provides + # dependency data for installed packages only - this data is slow to + # gather. + # :provides - Same as :all but pulls in Provides data for available packages as well. + # Used as a last resort when we can't find a Provides match. + # :installed - Trigger a run of "yum-dump.py --installed", only reads the local rpm + # db. Used between client runs for a quick refresh. + # :none - Do nothing, a call to one of the reload methods is required. + @next_refresh = :all + + @allow_multi_install = [] + + @extra_repo_control = nil + + # these are for subsequent runs if we are on an interval + Chef::Client.when_run_starts do + YumCache.instance.reload + end + end + + attr_reader :extra_repo_control + + # Cache management + # + + def yum_dump_path + ::File.join(::File.dirname(__FILE__), "yum-dump.py") + end + + def refresh + case @next_refresh + when :none + return nil + when :installed + reset_installed + # fast + opts = " --installed" + when :all + reset + # medium + opts = " --options --installed-provides" + when :provides + reset + # slow! + opts = " --options --all-provides" + else + raise ArgumentError, "Unexpected value in next_refresh: #{@next_refresh}" + end + + if @extra_repo_control + opts << " #{@extra_repo_control}" + end + + opts << " --yum-lock-timeout #{Chef::Config[:yum_lock_timeout]}" + + one_line = false + error = nil + + status = nil + + begin + status = shell_out!("#{python_bin} #{yum_dump_path}#{opts}", :timeout => Chef::Config[:yum_timeout]) + status.stdout.each_line do |line| + one_line = true + + line.chomp! + if line =~ %r{\[option (.*)\] (.*)} + if $1 == "installonlypkgs" + @allow_multi_install = $2.split + else + raise Chef::Exceptions::Package, "Strange, unknown option line '#{line}' from yum-dump.py" + end + next + end + + if line =~ %r{^(\S+) ([0-9]+) (\S+) (\S+) (\S+) \[(.*)\] ([i,a,r]) (\S+)$} + name = $1 + epoch = $2 + version = $3 + release = $4 + arch = $5 + provides = parse_provides($6) + type = $7 + repoid = $8 + else + Chef::Log.warn("Problem parsing line '#{line}' from yum-dump.py! " + + "Please check your yum configuration.") + next + end + + case type + when "i" + # if yum-dump was called with --installed this may not be true, but it's okay + # since we don't touch the @available Set in reload_installed + available = false + installed = true + when "a" + available = true + installed = false + when "r" + available = true + installed = true + end + + pkg = RPMDbPackage.new(name, epoch, version, release, arch, provides, installed, available, repoid) + @rpmdb << pkg + end + + error = status.stderr + rescue Mixlib::ShellOut::CommandTimeout => e + Chef::Log.error("#{yum_dump_path} exceeded timeout #{Chef::Config[:yum_timeout]}") + raise(e) + end + + if status.exitstatus != 0 + raise Chef::Exceptions::Package, "Yum failed - #{status.inspect} - returns: #{error}" + else + unless one_line + Chef::Log.warn("Odd, no output from yum-dump.py. Please check " + + "your yum configuration.") + end + end + + # A reload method must be called before the cache is altered + @next_refresh = :none + end + + def python_bin + yum_executable = which(yum_binary) + if yum_executable && shabang?(yum_executable) + shabang_or_fallback(extract_interpreter(yum_executable)) + else + Chef::Log.warn("Yum executable not found or doesn't start with #!. Using default python.") + "/usr/bin/python" + end + rescue StandardError => e + Chef::Log.warn("An error occurred attempting to determine correct python executable. Using default.") + Chef::Log.debug(e) + "/usr/bin/python" + end + + def extract_interpreter(file) + ::File.open(file, "r", &:readline)[2..-1].strip + end + + # dnf based systems have a yum shim that has /bin/bash as the interpreter. Don't use this. + def shabang_or_fallback(interpreter) + if interpreter == "/bin/bash" + Chef::Log.warn("Yum executable interpreter is /bin/bash. Falling back to default python.") + "/usr/bin/python" + else + interpreter + end + end + + def shabang?(file) + ::File.open(file, "r") do |f| + f.read(2) == '#!' + end + rescue Errno::ENOENT + false + end + + def reload + @next_refresh = :all + end + + def reload_installed + @next_refresh = :installed + end + + def reload_provides + @next_refresh = :provides + end + + def reset + @rpmdb.clear + end + + def reset_installed + @rpmdb.clear_installed + end + + # Querying the cache + # + + # Check for package by name or name+arch + def package_available?(package_name) + refresh + + if @rpmdb.lookup(package_name) + return true + else + if package_name =~ %r{^(.*)\.(.*)$} + pkg_name = $1 + pkg_arch = $2 + + if matches = @rpmdb.lookup(pkg_name) + matches.each do |m| + return true if m.arch == pkg_arch + end + end + end + end + + return false + end + + # Returns a array of packages satisfying an RPMDependency + def packages_from_require(rpmdep) + refresh + @rpmdb.whatprovides(rpmdep) + end + + # Check if a package-version.arch is available to install + def version_available?(package_name, desired_version, arch = nil) + version(package_name, arch, true, false) do |v| + return true if desired_version == v + end + + return false + end + + # Return the source repository for a package-version.arch + def package_repository(package_name, desired_version, arch = nil) + package(package_name, arch, true, false) do |pkg| + return pkg.repoid if desired_version == pkg.version.to_s + end + + return nil + end + + # Return the latest available version for a package.arch + def available_version(package_name, arch = nil) + version(package_name, arch, true, false) + end + alias :candidate_version :available_version + + # Return the currently installed version for a package.arch + def installed_version(package_name, arch = nil) + version(package_name, arch, false, true) + end + + # Return an array of packages allowed to be installed multiple times, such as the kernel + def allow_multi_install + refresh + @allow_multi_install + end + + def enable_extra_repo_control(arg) + # Don't touch cache if it's the same repos as the last load + unless @extra_repo_control == arg + @extra_repo_control = arg + reload + end + end + + def disable_extra_repo_control + # Only force reload when set + if @extra_repo_control + @extra_repo_control = nil + reload + end + end + + private + + def version(package_name, arch = nil, is_available = false, is_installed = false) + package(package_name, arch, is_available, is_installed) do |pkg| + if block_given? + yield pkg.version.to_s + else + # first match is latest version + return pkg.version.to_s + end + end + + if block_given? + return self + else + return nil + end + end + + def package(package_name, arch = nil, is_available = false, is_installed = false) + refresh + packages = @rpmdb[package_name] + if packages + packages.each do |pkg| + if is_available + next unless @rpmdb.available?(pkg) + end + if is_installed + next unless @rpmdb.installed?(pkg) + end + if arch + next unless pkg.arch == arch + end + + if block_given? + yield pkg + else + # first match is latest version + return pkg + end + end + end + + if block_given? + return self + else + return nil + end + end + + # Parse provides from yum-dump.py output + def parse_provides(string) + ret = [] + # ['atk = 1.12.2-1.fc6', 'libatk-1.0.so.0'] + string.split(", ").each do |seg| + # 'atk = 1.12.2-1.fc6' + if seg =~ %r{^'(.*)'$} + ret << RPMProvide.parse($1) + end + end + + return ret + end + + end # YumCache + end + end + end +end diff --git a/spec/unit/provider/package/yum/yum_cache_spec.rb b/spec/unit/provider/package/yum/yum_cache_spec.rb new file mode 100644 index 0000000000..e9d615d734 --- /dev/null +++ b/spec/unit/provider/package/yum/yum_cache_spec.rb @@ -0,0 +1,27 @@ +# +# Author:: Adam Jacob (<adam@chef.io>) +# Copyright:: Copyright 2008-2016, 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 "spec_helper" + +describe Chef::Provider::Package::Yum::YumCache do + + it "can find yum-dump.py" do + expect(File.exist?(Chef::Provider::Package::Yum::YumCache.instance.yum_dump_path)).to be true + end + +end |