File zypper-lifecycle of Package zypper-lifecycle-plugin

#!/usr/bin/ruby

require 'rexml/document'
require 'fileutils'
require 'open3'
require 'csv'
require 'optparse'
require 'optparse/time'
require 'date'

module SUSE
  module Zypper
    class << self

      def call(args, quiet = true)
        cmd = "zypper #{args}"
        output, error, status = Open3.capture3({ 'LC_ALL' => 'C' }, cmd) {|_stdin, stdout, _stderr, _wait_thr| stdout.read }

        # Don't fail when zypper exits with 104 (no product found) or 6 (no repositories)
        valid_exit_codes = [0, 104, 6]
        # Catching interactive failures of zypper. --non-interactive always returns with exit code 0 here
        if !valid_exit_codes.include?(status.exitstatus) || error.include?('ABORT request')
          error = error.empty? ? output.strip : error.strip
          print error
#        e = (cmd.include? 'zypper') ? Connect::ZypperError.new(status.exitstatus, error) : Connect::SystemCallError
#        raise e, error
        end
        output.strip unless quiet
      end

      def xml_call(args, root, subpath = [])
        zypper_out = call(args, false)
        xml_doc = REXML::Document.new(zypper_out, compress_whitespace: [])
        ary_of_products_hashes = xml_doc.root.elements[root].elements.map do |e|
          h = {}
          e.attributes.each { |name, value| h[name.to_sym] = value } # add attributes
          subpath.each do |sp|
            next if e.elements[sp].nil?
            fsp = sp.gsub(/\//, '_')
            e.elements[sp].attributes.each { |name, value| h["#{fsp}_#{name}".to_sym] = value } # add attributes of requested sub-elements
            if e.elements[sp].has_text?()
              h["#{fsp}".to_sym] = e.elements[sp].get_text().to_s
            end
          end
          h
        end
      end

    end
  end

  class Lifecycle

    # allow sorting by numeric value
    NowTS   = 0
    NaTS    = 1000000000000000
    NeverTS = 1000000000000001
    ProductFormatStr = "%-55s %s\n"

    def initialize(verbose)
        @verbose = verbose
        @retval = 0
    end

    def load_zypper_data
      repo_list = Zypper::xml_call("--no-refresh -x lr", 'repo-list')
      repo_alias = {}
      repo_list.each do |r|
        repo_alias[r[:alias]] = r[:name]
        repo_alias[r[:name]] = r[:name]
      end

      product_list = Zypper::xml_call("--no-refresh -x pd --xmlfwd codestream", 'product-list',
          ['endoflife', 'registerflavor', 'xmlfwd/codestream/name', 'xmlfwd/codestream/endoflife'])

      product_by_repo = {}
      @installed_products = {}
      product_list.each do |p|
        if !p.key?(:endoflife_time_t)
           p[:endoflife_time_t] = NeverTS
        else
          p[:endoflife_time_t] = p[:endoflife_time_t].to_i
          p[:endoflife_time_t] = NaTS if p[:endoflife_time_t] == 0
        end
        if !p.key?(:xmlfwd_codestream_endoflife)
           p[:codestream_endoflife_time_t] = NeverTS
        else
          begin
            p[:codestream_endoflife_time_t] = Date::parse(p[:xmlfwd_codestream_endoflife]).strftime("%s").to_i
          rescue
            p[:codestream_endoflife_time_t] = NaTS
          end
        end
        if p.key?(:xmlfwd_codestream_name)
          p[:codestream_name] = p[:xmlfwd_codestream_name]
        else
          p[:codestream_name] = ''
        end
        repo = repo_alias[p[:repo]]
        product_by_repo[repo] = p
        @installed_products[p[:name]] =  p if p[:installed] == "true"
      end

      package_list = Zypper::xml_call("--no-refresh -x se -s -t package", 'search-result/solvable-list')
      @installed_package = {}
      package_list.each do |p|
        if p[:kind] == 'package' && p[:status] == 'installed'
          @installed_package[p[:name]] ||= []
          @installed_package[p[:name]] << p
        end
      end

      #hack - guess that repos <name>-Pool and <name>-Updates are the same product
      repo_alias.values.each do |name|
        updates = name.gsub(/-Pool/, '-Updates')
        if updates != name && product_by_repo[name] && !product_by_repo[updates]
          product_by_repo[updates] = product_by_repo[name]
          print "#{updates} is an update repo for #{product_by_repo[name][:name]}\n" if @verbose
        end
      end

      update_list = Zypper::xml_call("--no-refresh -x lu", 'update-status/update-list', ['source'])
      update_list.each do |u|
        (@installed_package[u[:name]] || []).each do |p|
          if p[:arch] == u[:arch]
            p[:update_edition] = u[:edition]
            p[:repository] = repo_alias[u[:source_alias]] if p[:repository] == "(System Packages)"
          end
        end
      end

      package_list.each do |p|
        p[:product] = product_by_repo[p[:repository]]
      end

      # if there are the same versions of a package in multiple products, keep the one with longest product life
      @installed_package.values.each do |p_list|
        p_list.sort_by!{ |p| [p[:edition], p[:product] ? p[:product][:endoflife_time_t] : 0] }.reverse!.uniq!{ |p| p[:edition] }
      end

    end

    def load_lifecycle_data()
      @installed_products.values.each do |product|
        file = "/var/lib/lifecycle/data/#{product[:name]}.lifecycle"
        print "trying to load #{file}... " if @verbose
        begin
          CSV.foreach(file, {:skip_blanks => true, :skip_lines => /^\s*#/ }) do |line|
            name, version, date = line.map(&:strip)
            date = Time.parse(date).strftime("%s")
            version_re = Regexp.new( '^' + Regexp.quote(version).gsub(/\\\*/, '.*') + '$')
            #print "match #{name} #{version_re}\n"
            (@installed_package[name] || []).each do |p|
              #print "#{p}\n"
              if version_re.match(p[:edition])
                p[:package_eol] = date
              end
              if version_re.match(p[:update_edition])
                p[:update_package_eol] = date
              end
            end
          end
          print "ok\n" if @verbose
        rescue Errno::ENOENT => e
          print "#{e.message}\n" if @verbose
        rescue Exception => e
          print "\nError parsing #{file}: #{e.message}\n"
          @retval = 2
        end
      end
    end

    def solve_package_eol()
      now = Time.now.strftime("%s").to_i

      @installed_package.values.flatten.each do |p|
        eol = nil
        if p[:package_eol] # eol specified in lifecycle file
          eol = p[:package_eol].to_i
          eol = NowTS if eol <= now
        end
        eol = NowTS if !eol && p[:update_edition] # update exists -> this package is expired
        eol = p[:product][:endoflife_time_t].to_i if !eol && p[:product] && p[:product][:endoflife_time_t] # default to product eol
        eol = NeverTS if !eol
        p[:eol] = eol

        if p[:update_edition]
          up_eol = nil
          up_eol = p[:update_package_eol] if p[:update_package_eol] # eol specified in lifecycle file
          up_eol = p[:product][:endoflife_time_t] if !up_eol && p[:product] && p[:product][:endoflife_time_t] # default to product eol
          p[:update_eol] = up_eol.to_i if up_eol
        end
      end
    end

    def eol_string(eol_ts, vendor_suse)
      eol = ''
      eol = '-' if eol_ts == NeverTS
      if eol_ts == NaTS
        eol = 'n/a'
        eol += '*' if vendor_suse
      end
      eol = 'Now' if eol_ts == NowTS
      eol = Time.at(eol_ts).strftime('%F') if eol_ts && eol == ''
      eol
    end

    def print_product(p)
      vendor_suse = Regexp.new("^SUSE").match(p[:vendor])
      if p.key?(:xmlfwd_codestream_name)
        if p[:xmlfwd_codestream_name] != @printed_codestream
          printf(ProductFormatStr, "Codestream: " + p[:xmlfwd_codestream_name], eol_string(p[:codestream_endoflife_time_t], vendor_suse))
          @printed_codestream = p[:xmlfwd_codestream_name]
        end
        printf(ProductFormatStr, "    " + p[:summary], eol_string(p[:endoflife_time_t], vendor_suse))
      else
        printf(ProductFormatStr, p[:summary], eol_string(p[:endoflife_time_t], vendor_suse))
        @printed_codestream = nil
      end
    end

    def print_package(p)
      vendor_suse = false
      vendor_suse = true if p[:product] && Regexp.new("^SUSE").match(p[:product][:vendor])
      eol = eol_string(p[:eol], vendor_suse)
      up = ''
      if p[:update_edition]
        up = ", available update to #{p[:update_edition]}"
        up_eol = ''
        up_eol = eol_string(p[:update_eol], vendor_suse) if p[:update_eol] && p[:update_eol] < NaTS
      end
      printf("%-55s %-40s %s\n", "#{p[:name]}-#{p[:edition]}", eol + up, up_eol)
    end

    def report_products(products, msg)
      base_products = products.select{ |p| p[:isbase] == 'true'}.sort_by.with_index { |p, idx| [p[:codestream_name], p[:endoflife_time_t].to_i, idx] }
      modules = products.select{ |p| p[:registerflavor] == 'module'}.sort_by.with_index { |p, idx| [p[:codestream_name], p[:endoflife_time_t].to_i, idx] }
      extensions = products.select{ |p| p[:registerflavor] == 'extension'}.sort_by.with_index { |p, idx| [p[:codestream_name], p[:endoflife_time_t].to_i, idx] }

      if base_products.length > 0
        printf("\n" + ProductFormatStr, "Product #{msg}", "")
        @printed_codestream = nil
        base_products.each do |product|
          print_product(product)
        end
      end

      if modules.length > 0
        printf("\n" + ProductFormatStr, "Module #{msg}", "")
        @printed_codestream = nil
        modules.each do |product|
          print_product(product)
        end
      end

      if extensions.length > 0
        printf("\n" + ProductFormatStr, "Extension #{msg}", "")
        @printed_codestream = nil
        extensions.each do |product|
          print_product(product)
        end
      end

    end

    def report()
      report_products(@installed_products.values, "end of support")

      packages = @installed_package.values.flatten.select {|p| p[:package_eol] || p[:update_edition] }
      if packages.length > 0
        print "\nPackage end of support if different from product:\n"
        packages.sort_by.with_index { |p, idx| [p[:eol], idx] }.each do |p|
          print_package(p) if p[:package_eol] || p[:update_edition]
        end
      else
        print "\nNo packages with end of support different from product.\n"
      end
      print "\n*) See https://www.suse.com/lifecycle for latest information\n"
    end

    def report_packages(list)
      print "\nPackage end of support:\n"
      list.each do |name|
        p_list = @installed_package[name]
        if !p_list
          print "#{name} is not installed\n"
          @retval = 1
        else
          p_list.each { |p| print_package(p) }
        end
      end
      print "\n*) See http://www.suse.com/lifecycle for latest information\n"
    end

    def report_deadline(date)
      date_ts = date.strftime("%s").to_i
      date_str = Time.at(date_ts).strftime('%F')

      products = @installed_products.values.select {|p| p[:endoflife_time_t] && p[:endoflife_time_t].to_i <= date_ts or p[:codestream_endoflife_time_t] && p[:codestream_endoflife_time_t] <= date_ts}
      if products.length > 0
        report_products(products, "end of support before #{date_str}")
      else
        print "\nNo products whose support ends before #{date_str}.\n"
      end
      packages = @installed_package.values.flatten.select {|p| p[:eol] && p[:eol] <= date_ts }
      if packages.length > 0
        dif_packages = packages.select {|p| p[:package_eol] || p[:update_edition] }
        if dif_packages.length > 0
          print "\nPackage end of support before #{date_str}:\n"
          dif_packages.sort_by.with_index { |p, idx| [p[:eol], idx] }.each do |p|
            print_package(p)
          end
        else
          print "\nNo packages with end of support different from product.\n"
        end
      else
        print "\nNo packages whose support ends before #{date_str}.\n"
      end
      print "\n*) See https://www.suse.com/lifecycle for latest information\n"
    end

    def exit_val()
      exit @retval
    end

  end
end

options = {}

OptionParser.new do |opts|
  opts.banner = "Usage: zypper lifecycle [ -v | --verbose ] --days N | --date <date> | <package> ..."

  opts.on("--days N", Integer, "Show packages/products whose support ends in N days (from now)") do |d|
    options[:date] = Time.now + d * 60 * 60 * 24
  end
  opts.on("--date YYYY-MM-DD", Time, "Show packages/products whose support ends before the specified date") do |d|
    options[:date] = d
  end
  opts.on("-v", "--[no-]verbose", "Run verbosely") do |v|
    options[:verbose] = v
  end
end.parse!

lifecycle = SUSE::Lifecycle.new(options[:verbose])
lifecycle.load_zypper_data
lifecycle.load_lifecycle_data
lifecycle.solve_package_eol

if options[:date]
  lifecycle.report_deadline(options[:date])
elsif ARGV.empty?
  lifecycle.report
else
  lifecycle.report_packages(ARGV)
end

lifecycle.exit_val
openSUSE Build Service is sponsored by