File zypper-migration of Package zypper-migration-plugin.2374

#!/usr/bin/ruby

require 'optparse'
require 'fileutils'
require "suse/connect"

class ProductVersion
  include Comparable
  attr :v

  def cmp_array(a1, a2)
    return 0 if a1.length == 0 && a2.length == 0
    first1 = a1.length > 0 ? a1.shift : "0"
    first2 = a2.length > 0 ? a2.shift : "0"

    cmp = first1.to_i <=> first2.to_i
    cmp = cmp_array(a1, a2) if cmp == 0
    return cmp
  end

  def <=>(v2)
    a_ver, a_rel = @v.split("-")
    b_ver, b_rel = v2.v.split("-")
    cmp_array(a_ver.split('.'), b_ver.split('.'))
  end
  def initialize(v)
    @v = v
  end
  def inspect
    @v
  end
end



def create_tarball(tarball_path, root, paths)
  # tar reports an error if a file does not exist.
  # So we have to check this before.
  existing_paths = paths.select { |p| File.exist?(File.join(root, p)) }

  # ensure directory exists
  FileUtils.mkdir_p(File.dirname(tarball_path))

  paths_without_prefix = existing_paths.map {|p| p.start_with?("/") ? p[1..-1] : p }

  command = "tar c -C '#{root}'"
  # no shell escaping here, but we backup reasonable files and want to allow globs
  command << " " + paths_without_prefix.join(" ")
  # use parallel gzip for faster compression (uses all available CPU cores)
  command << " | pigz - > '#{tarball_path}'"
  res = system command


  # tarball can contain sensitive data, so prevent read to non-root
  # do it for sure even if tar failed as it can contain partial content
  FileUtils.chmod(0600, tarball_path) if File.exist?(tarball_path)

  raise "Failed to create backup." unless res
end

def create_restore_script(script_path, tarball_path, paths)
  paths_without_prefix = paths.map {|p| p.start_with?("/") ? p[1..-1] : p }

  # remove leading "/" from tarball to allow to run it from different root
  tarball_path = tarball_path[1..-1] if tarball_path.start_with?("/")
  script_content = <<EOF
#! /bin/sh
# change root to first parameter or use / as default
# it is needed to allow restore in installation
cd ${1:-/}
#{paths_without_prefix.map{ |p| "rm -rf #{p}" }.join("\n")}

tar xvf #{tarball_path} --overwrite
# return back to original dir
cd -
EOF

  File.write(script_path, script_content)
  # allow execution of script
  FileUtils.chmod(0744, script_path)
end


def zypp_backup()
  tarball_path = "/var/adm/backup/system-upgrade/repos.tar.gz"
  paths = [
            "/etc/zypp/repos.d",
            "/etc/zypp/credentials.d",
            "/etc/zypp/services.d"
          ]

  create_tarball(tarball_path, '/', paths)

  script_path = "/var/adm/backup/system-upgrade/repos.sh"
  create_restore_script(script_path, tarball_path, paths)
end

def zypp_restore()
  system "sh /var/adm/backup/system-upgrade/repos.sh >/dev/null"
end


options = {
    :allow_vendor_change => false,
    :verbose => false,
    :quiet => false,
    :non_interactive => false,
    :query => false,
    :migration => 0,
    :repo => [],
    :from => [],
    :auto_agree => false,
    :debug_solver => false,
    :recommends => false,
    :no_recommends => false,
    :replacefiles => false,
    :details => false,
    :download => nil
}

STDOUT.sync = true

save_argv = Array.new(ARGV)

OptionParser.new do |opts|
  opts.banner = "Usage: zypper migration [options]"

  opts.on("-v", "--[no-]allow-vendor-change", "Allow vendor change") do |v|
    options[:allow_vendor_change] = v
  end

  opts.on("-v", "--[no-]verbose", "Increase verbosity") do |v|
    options[:verbose] = v
  end

  opts.on("-q", "--[no-]quiet", "Suppress normal output, print only error messages") do |q|
    options[:quiet] = q
  end

  opts.on("-n", "--non-interactive", "Do not ask anything, use default answers automatically") do |n|
    options[:non_interactive] = n
  end

  opts.on("--query", "Query available migration options and exit") do |q|
    options[:query] = q
  end

  opts.on("--disable-repos", "Disable obsolete repositories without asking") do |d|
    options[:disable_repos] = d
  end

  opts.on("--migration N", OptionParser::DecimalInteger, "Select migration option N") do |n|
    options[:migration] = n
  end

  opts.on("--from REPO", "Restrict upgrade to specified repository") do |r|
    options[:from] << r
  end

  opts.on("-r", "--repo REPO", "Load only the specified repository") do |r|
    options[:repo] << r
  end

  opts.on("-l", "--auto-agree-with-licenses", "Automatically say 'yes' to third party license confirmation prompt") do |a|
    options[:auto_agree] = a
  end

  opts.on("--debug-solver", "Create solver test case for debugging") do |a|
    options[:debug_solver] = a
  end

  opts.on("--recommends", "Install also recommended packages") do
    options[:recommends] = true
  end

  opts.on("--no-recommends", "Do not install recommended packages") do
    options[:no_recommends] = true
  end

  opts.on("--replacefiles", "Install the packages even if they replace files from other packages") do |a|
    options[:replacefiles] = a
  end

  opts.on("--details", "Show the detailed installation summary") do |a|
    options[:details] = a
  end

  opts.on("--download MODE", ["only", "in-advance", "in-heaps", "as-needed"],"Set the download-install mode") do |a|
    options[:download] = a
  end

  opts.on("--download-only", "Replace repositories and download the packages, do not install. WARNING: Upgrade with 'zypper dist-upgrade' as soon as possible.") do |d|
    options[:download] = "only" if d
  end

end.parse!

cmd = "zypper " +
      (options[:non_interactive] ? "--non-interactive " : "") +
      (options[:verbose] ? "--verbose " : "") +
      (options[:quiet] ? "--quiet " : "") +
      " refresh"
print "\nExecuting '#{cmd}'\n\n" unless options[:quiet]
if !system cmd
  print print "repository refresh failed, exiting\n"
  exit 1
end

cmd = "zypper " +
      (options[:non_interactive] ? "--non-interactive " : "") +
      (options[:verbose] ? "--verbose " : "") +
      (options[:quiet] ? "--quiet " : "") +
      " --no-refresh patch-check --updatestack-only"
print "\nExecuting '#{cmd}'\n\n" unless options[:quiet]
if !system cmd
  if $?.exitstatus >= 100
    # install pending updates and restart
    cmd = "zypper " +
          (options[:non_interactive] ? "--non-interactive " : "") +
          (options[:verbose] ? "--verbose " : "") +
          (options[:quiet] ? "--quiet " : "") +
          " --no-refresh patch --updatestack-only"
    print "\nExecuting '#{cmd}'\n\n" unless options[:quiet]
    system cmd

    # stop infinite restarting
    # check that the patches were really installed
    if ! system "zypper --non-interactive --quiet --no-refresh patch-check --updatestack-only > /dev/null"
      print "patch failed, exiting.\n"
      exit 1
    end

    print "\nRestarting the migration script...\n" unless options[:quiet]
    exec $0, *save_argv
  end
  exit 1
end
print "\n" unless options[:quiet]

begin
  system_products = SUSE::Connect::Migration::system_products

  release_package_missing = false
  system_products.each do |ident|
    begin
      # if a release package for registered product is missing -> try install it
      SUSE::Connect::Migration.install_release_package(ident.identifier)
    rescue => e
      release_package_missing = true
      print "Can't install release package for registered product #{ident.identifier}\n" unless options[:quiet]
      print "#{e.class}: #{e.message}\n" unless options[:quiet]
    end
  end

  if release_package_missing
    # some release packages are missing and can't be installed
    print "Calling SUSEConnect rollback to make sure SCC is synchronized with the system state.\n" unless options[:quiet]
    SUSE::Connect::Migration.rollback

    # re-read the list of products
    system_products = SUSE::Connect::Migration::system_products
  end

  if options[:verbose]
    print "Installed products:\n"
    system_products.each {|p|
      printf "  %-25s %s\n", "#{p.identifier}/#{p.version}/#{p.arch}", p.summary
    }
    print "\n"
  end
rescue => e
  print "Can't determine the list of installed products: #{e.class}: #{e.message}\n"
  exit 1
end

if system_products.length == 0
  print "No products found, migration is not possible.\n"
  exit 1
end

begin
  migrations_all = SUSE::Connect::YaST.system_migrations system_products
rescue => e
  print "Can't get available migrations from server: #{e.class}: #{e.message}\n"
  exit 1
end

#preprocess the migrations lists
migrations = Array.new
migrations_unavailable = Array.new
migrations_all.each do |migration|
  migr_available = true
  migration.each do |p|
    p.available = !defined?(p.available) || p.available
    p.already_installed = !! system_products.detect { |ip| ip.identifier.eql?(p.identifier) && ip.version.eql?(p.version) && ip.arch.eql?(p.arch) }
    if !p.available
      migr_available = false
    end
  end
  # put already_installed products last and base products first
  migration = migration.sort_by.with_index { |p, idx| [p.already_installed ? 1 : 0, p.base ? 0 : 1, idx] }
  if migr_available
    migrations << migration
  else
    migrations_unavailable << migration
  end
end

if migrations_unavailable.length > 0 && !options[:quiet]
  print "Unavailable migrations (product is not mirrored):\n\n"
  migrations_unavailable.each do |migration|
    migration.each do |p|
      print "        #{p.friendly_name}" + (p.available ? "" : " (not available)") + (p.already_installed ? " (already installed)" : "") + "\n"
    end
    print "\n"
  end
  print "\n"
end

if migrations.length == 0
  print "No migration available.\n\n" unless options[:quiet]
  exit 0
end

migration_num = options[:migration]
if options[:non_interactive] && migration_num == 0
  # select the first option
  migration_num = 1
end


while migration_num <= 0 || migration_num > migrations.length do 
  print "Available migrations:\n\n"
  migrations.each_with_index do |migration, index|
    printf "   %2d |", index + 1
    migration.each do |p|
      print " #{p.friendly_name}" + (p.already_installed ? " (already installed)" : "") + "\n       "
    end
    print "\n"
  end
  print "\n"
  if options[:query]
    exit 0
  end
  while migration_num <= 0 || migration_num > migrations.length do
    print "[num/q]: "
    choice = gets.chomp 
    exit 0 if choice.eql?("q") || choice.eql?("Q")
    migration_num = choice.to_i
  end
end

migration = migrations[migration_num - 1]

cmd = "snapper create --type pre --print-number --description 'before online migration'"
print "\nExecuting '#{cmd}'\n\n" unless options[:quiet]
pre_snapshot_num = `#{cmd}`.to_i

ENV['DISABLE_SNAPPER_ZYPP_PLUGIN'] = '1'

base_product_version = nil # unknown yet

result = false
fs_inconsistent = false
msg = "Preparing migration"

begin
  # allow interrupt only at specified points
  # we have to check zypper exitstatus == 8 even after interrupt
  interrupted = false
  trap('INT') { interrupted = true }
  trap('TERM') { interrupted = true }

  zypp_backup

  raise "Interrupted." if interrupted

  migration.each do |p|
    msg = "Upgrading product #{p.friendly_name}"
    print "#{msg}.\n" unless options[:quiet]
    service = SUSE::Connect::YaST.upgrade_product p

    unless service[:obsoleted_service_name].empty?
      msg = "Removing service #{service[:obsoleted_service_name]}"
      print "#{msg}.\n" if options[:verbose]
      SUSE::Connect::Migration::remove_service service[:obsoleted_service_name]
    end

    SUSE::Connect::Migration::find_products(p.identifier).each do |available_product|
      # filter out "(System Packages)" and already disabled repos
      next unless SUSE::Connect::Migration::repositories.detect { |r| r[:name].eql?(available_product[:repository]) && r[:enabled] != 0 }
      if ProductVersion.new(available_product[:edition]) < ProductVersion.new(p.version)
        print "Found obsolete repository #{available_product[:repository]}" unless options[:quiet]
        if options[:non_interactive] || options[:disable_repos]
          print "... disabling.\n" unless options[:quiet]
          SUSE::Connect::Migration::disable_repository available_product[:repository]
        else
          while true
            print "\nDisable obsolete repository #{available_product[:repository]} [y/n] (y): "
            choice = gets.chomp
            raise "Interrupted." if interrupted
            if choice.eql?('n') || choice.eql?('N')
              print "\n"
              break
            end
            if  choice.eql?('y') || choice.eql?('Y')|| choice.eql?('')
              print "... disabling.\n"
              SUSE::Connect::Migration::disable_repository available_product[:repository]
              break
            end
          end
        end
      end
    end

    msg = "Adding service #{service[:name]}"
    print "#{msg}.\n" if options[:verbose]
    SUSE::Connect::Migration::add_service service[:url], service[:name]

    # store the base product version
    if p.base
      base_product_version = p.version
    end
    raise "Interrupted." if interrupted
  end

  cmd = "zypper " +
        (base_product_version ? "--releasever #{base_product_version} " : "") +
        "ref -f"
  msg = "Executing '#{cmd}'"
  print "\n#{msg}\n\n" unless options[:quiet]
  result = system cmd

  raise "Refresh of repositories failed." unless result
  raise "Interrupted." if interrupted

  cmd = "zypper " +
        (base_product_version ? "--releasever #{base_product_version} " : "") +
        (options[:non_interactive] ? "--non-interactive " : "") +
        (options[:verbose] ? "--verbose " : "") +
        (options[:quiet] ? "--quiet " : "") +
        " --no-refresh " +
        " dist-upgrade " +
        (options[:allow_vendor_change] ? "--allow-vendor-change " : "--no-allow-vendor-change ") +
        (options[:auto_agree] ? "--auto-agree-with-licenses " : "") +
        (options[:debug_solver] ? "--debug-solver " : "") +
        (options[:recommends] ? "--recommends " : "") +
        (options[:no_recommends] ? "--no-recommends " : "") +
        (options[:replacefiles] ? "--replacefiles " : "") +
        (options[:details] ? "--details " : "") +
        (options[:download] ? "--download #{options[:download]} " : "") +
        (options[:repo].map { |r| "--repo #{r}" }.join(" ")) +
        (options[:from].map { |r| "--from #{r}" }.join(" "))
  msg = "Executing '#{cmd}'"
  print "\n#{msg}\n\n" unless options[:quiet]
  result = system cmd
  fs_inconsistent = true if $?.exitstatus == 8
  raise "Interrupted." if interrupted

rescue => e
  print "#{msg}: #{e.class}: #{e.message}\n"
  result = false
end

print "\nMigration failed.\n\n" unless result || options[:quiet]

if fs_inconsistent
  print "The migration to the new service pack has failed. The system is most\n"
  print "likely in an inconsistent state.\n"
  print "\n"
  print "We strongly recommend to rollback to a snapshot created before the\n"
  print "migration was started (via selecting the snapshot in the boot menu\n"
  print "if you use snapper) or restore the system from a backup.\n"
  exit 2
end

if pre_snapshot_num > 0
  cmd = "snapper create --type post --pre-number #{pre_snapshot_num} --print-number --description 'after online migration'"
  print "\nExecuting '#{cmd}'\n\n" unless options[:quiet]
  post_snapshot_num = `#{cmd}`.to_i
#  Filesystem rollback - considered too dangerous
#  if !result && post_snapshot_num > 0 && fs_inconsistent
#    cmd = "snapper undochange #{pre_snapshot_num}..#{post_snapshot_num}"
#    unless options[:non_interactive]
#      while true
#        print "Perform filesystem rollback with '#{cmd}' [y/n] (y): "
#        choice = gets.chomp
#        print "\n"
#        if choice.eql?('n') || choice.eql?('N')
#          fs_inconsistent = false
#          break
#        end
#        if choice.eql?('y') || choice.eql?('Y')|| choice.eql?('')
#          break
#        end
#      end
#    end
#    if fs_inconsistent
#      print "\nExecuting '#{cmd}'\n\n" unless options[:quiet]
#      system cmd
#    end
#  end
end

if !result
  print "\nPerforming repository rollback...\n" unless options[:quiet]
  begin
    # restore repo configuration from backup file
    zypp_restore
    ret = SUSE::Connect::Migration.rollback
    print "Rollback successful.\n" unless options[:quiet]
  rescue => e
    print "Rollback failed: #{e.class}: #{e.message}\n"
  end
end

exit 1 unless result


openSUSE Build Service is sponsored by