kexec is a way for a Linux kernel to directly boot another Linux kernel without going through the usual BIOS startup sequence, which can take several minutes on enterprise servers. The big problem with kexec is that Linux distributions don't set it up for you when you install a kernel, and setting it up yourself is manual and error-prone, so not many people actually use it when rebooting their servers.
I wanted to learn some Ruby, (I've worked through this Rails tutorial but I haven't previously written anything substantial in Ruby) and so I wrote a Ruby script to help automate kexec somewhat. It can simply stage the latest installed kernel for kexec, or it has an interactive mode where you can choose a kernel from a list. It does both of these by searching for the GRUB configuration file, parsing it to get the kernel, initrd and kernel command line, and then calling kexec with these arguments.
My big concern here is with the obvious duplicate code in process_grub_config
and the functions it calls, load_kernels_grub
and load_kernels_grub2
. I know these bits need to be refactored, but I'm not familiar enough with the language to know the best way to go about it. In particular, it's necessary to parse GRUB 1 and GRUB 2 style configuration files differently, and these files can be in different locations depending on Linux distribution.
I also literally wrote this yesterday evening, and I might not have had enough coffee while writing it, so I'm open to suggestions on any other part of the code that might need improvement.
(Note: Because this code is meant to be part of the process of rebooting, I suggest testing in a virtual machine. Run the script with no arguments for a usage statement. I've personally tested it on EL6, EL7, Ubuntu 10.04, 12.04, and Debian wheezy and it should work properly on any Linux distirbution that uses GRUB 1 or GRUB 2.)
#!/usr/bin/env ruby
# kexec-reboot - Easily choose a kernel to kexec
require 'optparse'
# Find a mount point given the device special
def device_to_mount_point(device)
if File.ftype(device) != "blockSpecial" then
STDERR.puts("Device #{device} isn't a block device\n")
return nil
end
mount_point = nil
mounts = open("/proc/mounts").each_line do |mount|
line = mount.split
if line[0] == device then
mount_point = line[1]
break
end
end
mount_point = "" if mount_point == "/" # Eliminate double /
if mount_point.nil? then
STDERR.puts "Can't find the mount point for device #{device}\n"
return nil
end
mount_point
end
# Find a mount point given the GRUB device and device map
def device_map_to_mount_point(device, device_map)
dev = device.match(/(hd\d+)/)
part = device.match(/hd\d+,(\d+)/)
mount_point = device_map.match(/\(#{dev[1]}\)\s+(.+)$/)
mount_point_part = 1 + Integer(part[1]) if !part.nil?
device_path = "#{mount_point[1]}#{mount_point_part}"
if !File.exists?(device_path) then
STDERR.puts("Can't find the device #{device_path} from #{device}\n")
return nil
end
device_to_mount_point("#{mount_point[1]}#{mount_point_part}")
end
# Find a mount point given the device UUID
def uuid_to_mount_point(uuid)
begin
device = File.realpath("/dev/disk/by-uuid/#{uuid}")
rescue Errno::ENOENT
STDERR.puts "No such file or directory, uuid #{uuid}\n"
return nil
end
device_to_mount_point(device)
end
# Load the available kernels from the given GRUB 1 configuration file
def load_kernels_grub(config)
device_map = open("/boot/grub/device.map").read
entries = Array.new
config.scan(/title (.+?$).+?root \(([^\)]+)\).+?kernel ([^ ]+) (.+?)$.+?initrd (.+?$)/m).each do |entry|
mount_point = device_map_to_mount_point(entry[1], device_map)
name = entry[0].strip
kernel = "#{mount_point}#{entry[2]}"
initrd = "#{mount_point}#{entry[4]}"
cmdline = entry[3].strip
# Sanity check the kernel and initrd; they must be present
if !File.readable?(kernel) then
STDERR.puts "Kernel #{kernel} is not readable\n"
next
end
if !File.readable?(initrd) then
STDERR.puts "Initrd #{initrd} is not readable\n"
next
end
entries.push({
"name" => name,
"kernel" => kernel,
"initrd" => initrd,
"cmdline" => cmdline,
})
end
entries
end
# Load the available kernels from the given GRUB 2 configuration file
def load_kernels_grub2(config)
entries = Array.new
config.scan(/menuentry '([^']+)'.+?\{.+?search.+?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}).+?linux(16)?\s+([^ ]+) (.+?)$.+?initrd(16)?\s+(.+?)$.+?\}/m).each do |entry|
mount_point = uuid_to_mount_point(entry[1])
name = entry[0].strip
kernel = "#{mount_point}#{entry[3]}"
initrd = "#{mount_point}#{entry[6]}"
cmdline = entry[4].strip
# Sanity check the kernel and initrd; they must be present
if !File.readable?(kernel) then
STDERR.puts "Kernel #{kernel} is not readable\n"
next
end
if !File.readable?(initrd) then
STDERR.puts "Initrd #{initrd} is not readable\n"
next
end
entries.push({
"name" => name,
"kernel" => kernel,
"initrd" => initrd,
"cmdline" => cmdline,
})
end
entries
end
# Load a grub configuration file and process it
def process_grub_config
# TODO: Duplicate code smells, refactor this
# First, locate the grub configuration file
# We try GRUB 1 files first
["/boot/grub/menu.lst"].each do |file|
begin
entries = load_kernels_grub(open(file).read)
if !entries.empty? then
if $verbose then
puts "Read GRUB configuration from #{file}\n"
end
return entries
end
rescue Errno::EACCES
STDERR.puts("#{$!}\nYou must be root to run this utility.\n")
exit 1
rescue Errno::ENOENT
next
end
end
# Then we try GRUB 2 files
["/boot/grub2/grub.cfg", "/boot/grub/grub.cfg"].each do |file|
begin
entries = load_kernels_grub2(open(file).read)
if !entries.empty? then
if $verbose then
puts "Read GRUB configuration from #{file}\n"
end
return entries
end
rescue Errno::EACCES
STDERR.puts("#{$!}\nYou must be root to run this utility.\n")
exit 1
rescue Errno::ENOENT
next
end
end
STDERR.puts("Couldn't find a grub configuration anywhere!\n")
exit 1
end
def kexec(entry)
if $verbose then
print "Staging kernel #{entry['name']}\n"
end
fork do
exec "/sbin/kexec", "-l", "#{entry['kernel']}", "--append=#{entry['cmdline']}", "--initrd=#{entry['initrd']}"
end
end
def interactive_select_kernel
entries = process_grub_config
selection = nil
loop do
puts "\nSelect a kernel to stage:\n\n"
entries.each_with_index do |entry, index|
selection_number = index + 1
puts "#{selection_number}: #{entry['name']}\n"
end
print "\nYour selection: "
selection = gets.chomp
begin
selection = Integer(selection)
rescue ArgumentError
return nil
end
break if selection.between?(0, entries.count)
end
return nil if selection == 0
entries[selection - 1]
end
def select_latest_kernel
entries = process_grub_config
entries.first
end
options = {}
opts = OptionParser.new do |opts|
opts.banner = "Usage: kexec-reboot [options]"
opts.on("-i", "--interactive", "Choose the kernel to stage from a list") do |i|
options[:interactive] = i
end
opts.on("-l", "--latest", "Stage the latest kernel") do |l|
options[:latest] = l
end
opts.on("-r", "--reboot", "Reboot immediately after staging the kernel") do |r|
options[:reboot] = r
end
opts.on("-v", "--[no-]verbose", "Extra verbosity.") do |v|
$verbose = v
end
end
opts.parse!
if (options[:interactive]) then
entry = interactive_select_kernel
if (entry.nil?) then
STDERR.puts "Canceled.\n"
exit 1
end
elsif (options[:latest]) then
entry = select_latest_kernel
else
STDERR.puts opts.help
exit 1
end
if !entry.nil? then
entry = kexec(entry)
if options[:reboot] then
`shutdown -r now`
end
end
This code is now available on github and future changes will be published there.
After this was posted, these changes have been made (which can be seen in the github version):
- A bug which caused kexec to fail if a previous kernel had already been staged (e.g. via kexec or a kdump crash kernel) has been fixed.
- A bug which caused the script to fail to find the boot partition on certain older HP ProLiant servers has been fixed.
- Ruby hashes have been changed to use symbols as keys, rather than strings.
- Support was added for systems that boot with UEFI.
A number of further changes have been made and are now in github, including most of the suggestions given by 200_success.
In addition, after more extensive testing on a variety of servers (thanks to ewwhite) the following change was made:
- When processing a grub 1 configuration, first assume the kernel can be reached in either / or /boot, before trying to read the device.map file, because device.map is very frequently wrong due to post-installation hardware changes. This issue doesn't affect systems which boot with grub 2.
I'll be doing some more cleanup, and after I've incorporated the rest of the suggestions I'll post the new version for review.