anaconda/anaconda-40.22.3.13/pyanaconda/modules/storage/bootloader/grub2.py
2024-11-14 21:39:56 -08:00

607 lines
23 KiB
Python

#
# Copyright (C) 2019 Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# the GNU General Public License v.2, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY expressed or implied, including the implied warranties of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
# Public License for more details. You should have received a copy of the
# GNU General Public License along with this program; if not, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the
# source code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission of
# Red Hat, Inc.
#
import os
import re
from _ped import PARTITION_BIOS_GRUB # pylint: disable=no-name-in-module
from blivet.devicelibs import raid
from pyanaconda.modules.storage.bootloader.base import BootLoader, BootLoaderError
from pyanaconda.core import util
from pyanaconda.core.configuration.anaconda import conf
from pyanaconda.core.i18n import _
from pyanaconda.core.path import open_with_perm
from pyanaconda.core.product import get_product_name
from pyanaconda.anaconda_loggers import get_module_logger
log = get_module_logger(__name__)
__all__ = ["GRUB2", "IPSeriesGRUB2"]
class SerialConsoleOptions(object):
"""The serial console options."""
def __init__(self):
self.speed = None
self.parity = None
self.word = None
self.stop = None
self.flow = None
def _parse_serial_opt(arg):
"""Parse and split serial console options.
.. NOTE::
Documentation/kernel-parameters.txt says:
ttyS<n>[,options]
Use the specified serial port. The options are of
the form "bbbbpnf", where "bbbb" is the baud rate,
"p" is parity ("n", "o", or "e"), "n" is number of
bits, and "f" is flow control ("r" for RTS or
omit it). Default is "9600n8".
but note that everything after the baud rate is optional, so these are
all valid: 9600, 19200n, 38400n8, 9600e7r.
Also note that the kernel assumes 1 stop bit; this can't be changed.
"""
opts = SerialConsoleOptions()
m = re.match(r'\d+', arg)
if m is None:
return opts
opts.speed = m.group()
idx = len(opts.speed)
try:
opts.parity = arg[idx + 0]
opts.word = arg[idx + 1]
opts.flow = arg[idx + 2]
except IndexError:
pass
return opts
class GRUB2(BootLoader):
"""GRUBv2.
- configuration
- password (insecure), password_pbkdf2
http://www.gnu.org/software/grub/manual/grub.html#Invoking-grub_002dmkpasswd_002dpbkdf2
- users per-entry specifies which users can access, otherwise entry is unrestricted
- /etc/grub/custom.cfg
- how does grub resolve names of md arrays?
- disable automatic use of grub-mkconfig?
- on upgrades?
- BIOS boot partition (GPT)
- parted /dev/sda set <partition_number> bios_grub on
- can't contain a file system
- 31KiB min, 1MiB recommended
"""
name = "GRUB2"
# grub2 is a virtual provides that's provided by grub2-pc, grub2-ppc64le,
# and all of the primary grub components that aren't grub2-efi-${EFIARCH}
packages = ["grub2", "grub2-tools"]
_config_file = "grub.cfg"
_config_dir = "grub2"
_passwd_file = "user.cfg"
defaults_file = "/etc/default/grub"
terminal_type = "console"
stage2_max_end = None
_device_map_file = "device.map"
stage2_is_valid_stage1 = True
stage2_bootable = True
stage2_must_be_primary = False
# requirements for boot devices
stage2_device_types = ["partition", "mdarray", "btrfs volume", "btrfs subvolume"]
stage2_raid_levels = [raid.RAID0, raid.RAID1, raid.RAID4,
raid.RAID5, raid.RAID6, raid.RAID10]
stage2_raid_member_types = ["partition"]
stage2_raid_metadata = ["0", "0.90", "1.0", "1.2"]
_serial_consoles = ["ttyS"]
# XXX we probably need special handling for raid stage1 w/ gpt disklabel
# since it's unlikely there'll be a bios boot partition on each disk
def __init__(self):
super().__init__()
self.encrypted_password = ""
#
# configuration
#
@property
def config_dir(self):
""" Full path to configuration directory. """
return "/boot/" + self._config_dir
@property
def config_file(self):
""" Full path to configuration file. """
return "%s/%s" % (self.config_dir, self._config_file)
@property
def device_map_file(self):
""" Full path to device.map file. """
return "%s/%s" % (self.config_dir, self._device_map_file)
@property
def has_serial_console(self):
""" true if the console is a serial console. """
return any(self.console.startswith(sconsole) for sconsole in self._serial_consoles)
@property
def serial_command(self):
command = ""
if self.console and self.has_serial_console:
unit = self.console[-1]
command = ["serial"]
s = _parse_serial_opt(self.console_options)
if unit and unit != '0':
command.append("--unit=%s" % unit)
if s.speed and s.speed != '9600':
command.append("--speed=%s" % s.speed)
if s.parity:
if s.parity == 'o':
command.append("--parity=odd")
elif s.parity == 'e':
command.append("--parity=even")
if s.word and s.word != '8':
command.append("--word=%s" % s.word)
if s.stop and s.stop != '1':
command.append("--stop=%s" % s.stop)
command = " ".join(command)
return command
def write_config_images(self, config):
return True
@property
def stage2_format_types(self):
if get_product_name().startswith("Red Hat "): # pylint: disable=no-member
return ["xfs", "ext4", "ext3", "ext2"]
else:
return ["ext4", "ext3", "ext2", "btrfs", "xfs"]
#
# grub-related conveniences
#
def grub_device_name(self, device):
"""Return a grub-friendly representation of device.
Disks and partitions use the (hdX,Y) notation, while lvm and
md devices just use their names.
"""
disk = None
name = "(%s)" % device.name
if device.is_disk:
disk = device
elif hasattr(device, "disk"):
disk = device.disk
if disk is not None:
name = "(hd%d" % self.disks.index(disk)
if hasattr(device, "disk"):
lt = device.disk.format.label_type
name += ",%s%d" % (lt, device.parted_partition.number)
name += ")"
return name
def write_config_console(self, config):
if not self.console:
return
console_arg = "console=%s" % self.console
if self.console_options:
console_arg += ",%s" % self.console_options
self.boot_args.add(console_arg)
def write_device_map(self):
"""Write out a device map containing all supported devices."""
map_path = os.path.normpath(conf.target.system_root + self.device_map_file)
if os.access(map_path, os.R_OK):
os.rename(map_path, map_path + ".anacbak")
devices = self.disks
if self.stage1_device not in devices:
devices.append(self.stage1_device)
for disk in self.stage2_device.disks:
if disk not in devices:
devices.append(disk)
devices = [d for d in devices if d.is_disk]
if len(devices) == 0:
return
dev_map = open(map_path, "w")
dev_map.write("# this device map was generated by anaconda\n")
for drive in devices:
dev_map.write("%s %s\n" % (self.grub_device_name(drive),
drive.path))
dev_map.close()
def write_defaults(self):
defaults_file = "%s%s" % (conf.target.system_root, self.defaults_file)
defaults = open(defaults_file, "w+")
defaults.write("GRUB_TIMEOUT=%d\n" % self.timeout)
defaults.write("GRUB_DISTRIBUTOR=\"$(sed 's, release .*$,,g' /etc/system-release)\"\n")
defaults.write("GRUB_DEFAULT=saved\n")
defaults.write("GRUB_DISABLE_SUBMENU=true\n")
if self.console and self.has_serial_console:
defaults.write("GRUB_TERMINAL=\"serial console\"\n")
defaults.write("GRUB_SERIAL_COMMAND=\"%s\"\n" % self.serial_command)
else:
defaults.write("GRUB_TERMINAL_OUTPUT=\"%s\"\n" % self.terminal_type)
# this is going to cause problems for systems containing multiple
# linux installations or even multiple boot entries with different
# boot arguments
log.info("bootloader.py: used boot args: %s ", self.boot_args)
defaults.write("GRUB_CMDLINE_LINUX=\"%s\"\n" % self.boot_args)
defaults.write("GRUB_DISABLE_RECOVERY=\"true\"\n")
#defaults.write("GRUB_THEME=\"/boot/grub2/themes/system/theme.txt\"\n")
if self.use_bls and os.path.exists(conf.target.system_root + "/usr/sbin/new-kernel-pkg"):
log.warning("BLS support disabled due new-kernel-pkg being present")
self.use_bls = False
hv_type_path = "/sys/hypervisor/type"
if self.use_bls and os.access(hv_type_path, os.F_OK):
with open(hv_type_path, "r") as fd:
if fd.readline().strip() == "xen":
log.warning("BLS support disabled because is a Xen machine")
self.use_bls = False
if self.use_bls:
defaults.write("GRUB_ENABLE_BLSCFG=true\n")
defaults.close()
def _encrypt_password(self):
"""Make sure self.encrypted_password is set up properly."""
if self.encrypted_password:
return
if not self.password:
raise RuntimeError("cannot encrypt empty password")
(pread, pwrite) = os.pipe()
passwords = "%s\n%s\n" % (self.password, self.password)
os.write(pwrite, passwords.encode("utf-8"))
os.close(pwrite)
buf = util.execWithCapture("grub2-mkpasswd-pbkdf2", [],
stdin=pread,
root=conf.target.system_root)
os.close(pread)
self.encrypted_password = buf.split()[-1].strip()
if not self.encrypted_password.startswith("grub.pbkdf2."):
raise BootLoaderError("failed to encrypt boot loader password")
def write_password_config(self):
if not self.password and not self.encrypted_password:
return
users_file = "%s%s/%s" % (conf.target.system_root, self.config_dir, self._passwd_file)
header = open_with_perm(users_file, "w", 0o600)
# XXX FIXME: document somewhere that the username is "root"
self._encrypt_password()
password_line = "GRUB2_PASSWORD=" + self.encrypted_password
header.write("%s\n" % password_line)
header.close()
def write_config(self):
self.write_config_console(None)
# See if we have a password and if so update the boot args before we
# write out the defaults file.
if self.password or self.encrypted_password:
self.boot_args.add("rd.shell=0")
self.write_defaults()
# if we fail to setup password auth we should complete the
# installation so the system is at least bootable
try:
self.write_password_config()
except (BootLoaderError, OSError, RuntimeError) as e:
log.error("boot loader password setup failed: %s", e)
# make sure the default entry is the OS we are installing
if self.default is not None:
machine_id_path = conf.target.system_root + "/etc/machine-id"
if not os.access(machine_id_path, os.R_OK):
log.error("failed to read machine-id, default entry not set")
return
with open(machine_id_path, "r") as fd:
machine_id = fd.readline().strip()
default_entry = "%s-%s" % (machine_id, self.default.version)
rc = util.execWithRedirect(
"grub2-set-default",
[default_entry],
root=conf.target.system_root
)
if rc:
log.error("failed to set default menu entry to %s", get_product_name())
# set menu_auto_hide grubenv variable if we should enable menu_auto_hide
# set boot_success so that the menu is hidden on the boot after install
if conf.bootloader.menu_auto_hide:
rc = util.execWithRedirect(
"grub2-editenv",
["-", "set", "menu_auto_hide=1", "boot_success=1"],
root=conf.target.system_root
)
if rc:
log.error("failed to set menu_auto_hide=1")
# now tell grub2 to generate the main configuration file
rc = util.execWithRedirect(
"grub2-mkconfig",
["-o", self.config_file],
root=conf.target.system_root
)
if rc:
raise BootLoaderError("failed to write boot loader configuration")
#
# installation
#
@property
def install_targets(self):
""" List of (stage1, stage2) tuples representing install targets. """
# make sure we have stage1 and stage2 installed with redundancy
# so that boot can succeed even in the event of failure or removal
# of some of the disks containing the member partitions of the
# /boot array. If the stage1 is not a disk, it probably needs to
# be a partition on a particular disk (biosboot, prepboot), so only
# add the redundant targets if installing stage1 to a disk that is
# a member of the stage2 array.
stage2_parents = []
if self.stage1_device \
and self.stage2_device \
and self.stage1_device.is_disk \
and self.stage2_device.depends_on(self.stage1_device):
# Look for both mdraid and btrfs raid
if self.stage2_device.type == "mdarray" and \
self.stage2_device.level in self.stage2_raid_levels:
# Set parents to the list of partitions in the RAID
stage2_parents = self.stage2_device.parents
elif self.stage2_device.type == "btrfs subvolume" and \
self.stage2_device.parents[0].data_level in self.stage2_raid_levels:
# Set parents to the list of partitions in the parent volume
stage2_parents = self.stage2_device.parents[0].parents
if stage2_parents:
# If target disk contains any of /boot array's member
# partitions, set up stage1 on each member's disk.
return [(d.disk, self.stage2_device) for d in stage2_parents]
return super().install_targets
def install(self, args=None):
if args is None:
args = []
# XXX will installing to multiple drives work as expected with GRUBv2?
for (stage1dev, stage2dev) in self.install_targets:
grub_args = args + ["--no-floppy", stage1dev.path]
if stage1dev == stage2dev:
# This is hopefully a temporary hack. GRUB2 currently refuses
# to install to a partition's boot block without --force.
grub_args.insert(0, '--force')
else:
if self.keep_mbr:
grub_args.insert(0, '--grub-setup=/bin/true')
log.info("bootloader.py: mbr update by grub2 disabled")
else:
log.info("bootloader.py: mbr will be updated for grub2")
rc = util.execWithRedirect("grub2-install", grub_args,
root=conf.target.system_root,
env_prune=['MALLOC_PERTURB_'])
if rc:
raise BootLoaderError("boot loader install failed")
def write(self):
"""Write the bootloader configuration and install the bootloader."""
if self.skip_bootloader:
return
try:
self.write_device_map()
self.stage2_device.format.sync(root=conf.target.physical_root)
os.sync()
self.install()
os.sync()
self.stage2_device.format.sync(root=conf.target.physical_root)
finally:
self.write_config()
os.sync()
self.stage2_device.format.sync(root=conf.target.physical_root)
def check(self):
"""When installing to the mbr of a disk grub2 needs enough space
before the first partition in order to embed its core.img
Until we have a way to ask grub2 what the size is we check to make
sure it starts >= 512K, otherwise return an error.
"""
ret = True
base_gap_bytes = 32256 # 31.5KiB
advanced_gap_bytes = 524288 # 512KiB
self.errors = []
self.warnings = []
if self.stage1_device == self.stage2_device:
return ret
# These are small enough to fit
if self.stage2_device.type == "partition":
min_start = base_gap_bytes
else:
min_start = advanced_gap_bytes
if not self.stage1_disk:
return False
# If the first partition starts too low and there is no biosboot partition show an error.
error_msg = None
biosboot = False
for p in self.stage1_disk.children:
if p.format.type == "biosboot" or p.parted_partition.getFlag(PARTITION_BIOS_GRUB):
biosboot = True
break
start = p.parted_partition.geometry.start * p.parted_partition.disk.device.sectorSize
if start < min_start:
error_msg = _("%(deviceName)s may not have enough space for grub2 to embed "
"core.img when using the %(fsType)s file system on %(deviceType)s") \
% {"deviceName": self.stage1_device.name,
"fsType": self.stage2_device.format.type,
"deviceType": self.stage2_device.type}
if error_msg and not biosboot:
log.error(error_msg)
self.errors.append(error_msg)
ret = False
return ret
#
# miscellaneous
#
def has_windows(self, devices):
""" Potential boot devices containing non-linux operating systems. """
# make sure we don't clobber error/warning lists
errors = self.errors[:]
warnings = self.warnings[:]
ret = [d for d in devices if self.is_valid_stage2_device(d, linux=False, non_linux=True)]
self.errors = errors
self.warnings = warnings
return bool(ret)
# Add a warning about certain RAID situations to is_valid_stage2_device
def is_valid_stage2_device(self, device, linux=True, non_linux=False):
valid = super().is_valid_stage2_device(device, linux, non_linux)
# If the stage2 device is on a raid1, check that the stage1 device is also redundant,
# either by also being part of an array or by being a disk (which is expanded
# to every disk in the array by install_targets).
if self.stage1_device and self.stage2_device and \
self.stage2_device.type == "mdarray" and \
self.stage2_device.level in self.stage2_raid_levels and \
self.stage1_device.type != "mdarray":
if not self.stage1_device.is_disk:
msg = _("boot loader stage2 device %(stage2dev)s is on a multi-disk array, "
"but boot loader stage1 device %(stage1dev)s is not. "
"A drive failure in %(stage2dev)s could render the system unbootable.") % \
{"stage1dev": self.stage1_device.name,
"stage2dev": self.stage2_device.name}
self.warnings.append(msg)
elif not self.stage2_device.depends_on(self.stage1_device):
msg = _("boot loader stage2 device %(stage2dev)s is on a multi-disk array, "
"but boot loader stage1 device %(stage1dev)s is not part of this array. "
"The stage1 boot loader will only be installed to a single drive.") % \
{"stage1dev": self.stage1_device.name,
"stage2dev": self.stage2_device.name}
self.warnings.append(msg)
return valid
class IPSeriesGRUB2(GRUB2):
"""IPSeries GRUBv2"""
# GRUB2 sets /boot bootable and not the PReP partition. This causes the Open Firmware BIOS
# not to present the disk as a bootable target. If stage2_bootable is False, then the PReP
# partition will be marked bootable. Confusing.
stage2_bootable = False
terminal_type = "ofconsole"
#
# installation
#
def install(self, args=None):
if self.keep_boot_order:
log.info("leavebootorder passed as an option. Will not update the NVRAM boot list.")
else:
self.updateNVRAMBootList()
super().install(args=["--no-nvram"])
# This will update the PowerPC's (ppc) bios boot devive order list
def updateNVRAMBootList(self):
if not conf.target.is_hardware:
return
log.debug("updateNVRAMBootList: self.stage1_device.path = %s", self.stage1_device.path)
rc = util.execWithRedirect("bootlist",
["-m", "normal", "-o", self.stage1_device.path])
if rc:
log.error("Failed to update new boot device order")
#
# In addition to the normal grub configuration variable, add one more to set the size
# of the console's window to a standard 80x24
#
def write_defaults(self):
super().write_defaults()
defaults_file = "%s%s" % (conf.target.system_root, self.defaults_file)
defaults = open(defaults_file, "a+")
# The terminfo's X and Y size, and output location could change in the future
defaults.write("GRUB_TERMINFO=\"terminfo -g 80x24 console\"\n")
# Disable OS Prober on pSeries systems
# TODO: This will disable across all POWER platforms. Need to get
# into blivet and rework how it segments the POWER systems
# to allow for differentiation between PowerNV and
# PowerVM / POWER on qemu/kvm
defaults.write("GRUB_DISABLE_OS_PROBER=true\n")
defaults.close()
class PowerNVGRUB2(GRUB2):
"""PowerNV GRUBv2"""
def install(self, args=None):
"""installation should be a no-op, just writing the config is sufficient for the
firmware's bootloader (petitboot)
"""
pass