607 lines
23 KiB
Python
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
|