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