From 8a23cba0ff7e13d7a9fb57671737fe11c65cb562 Mon Sep 17 00:00:00 2001 From: Shelly Chang Date: Mon, 5 Jun 2023 15:47:11 +0800 Subject: [PATCH] pkg/boot: Add basic support for GRUB default boot entry Add support for the default boot entry when GRUB_DEFAULT=saved and GRUB_ENABLE_BLSCFG=true in /etc/default/grub. For this case grub.cfg would generate the below line: set default="${saved_entry}" and grubenv would contain the value of ${saved_entry}, which will be compared and used as the default boot entry. Here it only handles specifically for this case. The default boot entry will be assigned with an incremented linux.BootRank, therefore it will be displayed from the top of the boot menu. Testing method: With CentOS 8, set GRUB_DEFAULT=saved and GRUB_ENABLE_BLSCFG=true in /etc/default/grub, and then run 'grub2-mkconfig -o /boot/efi/EFI/centos/grub.cfg'. Use 'grubby --set-default' to set the default boot option, run 'cat /boot/*/grubenv' to check the value after saved_entry is expected. After reboot, 'boot' command can see the default boot option displayed at the top of the boot menu. Signed-off-by: Shelly Chang --- pkg/boot/bls/bls.go | 29 ++++++--- pkg/boot/bls/bls_test.go | 8 +-- pkg/boot/grub/grub.go | 65 +++++++++++++++++-- .../CentOS_8_Stream_x86_64_blscfg_sda1.json | 2 +- pkg/boot/localboot/localboot.go | 2 +- 5 files changed, 88 insertions(+), 18 deletions(-) diff --git a/pkg/boot/bls/bls.go b/pkg/boot/bls/bls.go index cdc630104c..4c4497e608 100644 --- a/pkg/boot/bls/bls.go +++ b/pkg/boot/bls/bls.go @@ -39,7 +39,7 @@ const ( // This function skips over invalid or unreadable entries in an effort // to return everything that is bootable. map variables is the parsed result // from Grub parser that should be used by BLS parser, pass nil if there's none. -func ScanBLSEntries(log ulog.Logger, fsRoot string, variables map[string]string) ([]boot.OSImage, error) { +func ScanBLSEntries(l ulog.Logger, fsRoot string, variables map[string]string, grubDefaultSavedEntry string) ([]boot.OSImage, error) { entriesDir := filepath.Join(fsRoot, blsEntriesDir) files, err := filepath.Glob(filepath.Join(entriesDir, "*.conf")) @@ -67,9 +67,16 @@ func ScanBLSEntries(log ulog.Logger, fsRoot string, variables map[string]string) for _, f := range files { identifier := strings.TrimSuffix(filepath.Base(f), ".conf") - img, err := parseBLSEntry(f, fsRoot, variables) + // If the config file name is the same as the Grub default option, pass true for grubDefaultFlag + var img boot.OSImage + var err error + if strings.Compare(identifier, grubDefaultSavedEntry) == 0 { + img, err = parseBLSEntry(f, fsRoot, variables, true) + } else { + img, err = parseBLSEntry(f, fsRoot, variables, false) + } if err != nil { - log.Printf("BootLoaderSpec skipping entry %s: %v", f, err) + l.Printf("BootLoaderSpec skipping entry %s: %v", f, err) continue } imgs[identifier] = img @@ -163,9 +170,8 @@ func getGrubvalue(variables map[string]string, key string) (string, error) { return "", nil } -func parseLinuxImage(vals map[string]string, fsRoot string, variables map[string]string) (boot.OSImage, error) { +func parseLinuxImage(vals map[string]string, fsRoot string, variables map[string]string, grubDefaultFlag bool) (boot.OSImage, error) { linux := &boot.LinuxImage{} - var cmdlines []string var tokens []string var value string @@ -242,11 +248,18 @@ func parseLinuxImage(vals map[string]string, fsRoot string, variables map[string // If both title and version were empty, so will this. linux.Name = strings.Join(name, " ") linux.Cmdline = strings.Join(cmdlines, " ") - linux.BootRank = blsDefaultRank + // If this is the default option, increase the BootRank by 1 + // when os.LookupEnv("BLS_BOOT_RANK") doesn't exist so it's not affected. if val, exist := os.LookupEnv("BLS_BOOT_RANK"); exist { if rank, err := strconv.Atoi(val); err == nil { linux.BootRank = rank } + } else { + if grubDefaultFlag { + linux.BootRank = blsDefaultRank + 1 + } else { + linux.BootRank = blsDefaultRank + } } return linux, nil @@ -255,7 +268,7 @@ func parseLinuxImage(vals map[string]string, fsRoot string, variables map[string // parseBLSEntry takes a Type #1 BLS entry and the directory of entries, and // returns a LinuxImage. // An error is returned if the syntax is wrong or required keys are missing. -func parseBLSEntry(entryPath, fsRoot string, variables map[string]string) (boot.OSImage, error) { +func parseBLSEntry(entryPath, fsRoot string, variables map[string]string, grubDefaultFlag bool) (boot.OSImage, error) { vals, err := parseConf(entryPath) if err != nil { return nil, fmt.Errorf("error parsing config in %s: %w", entryPath, err) @@ -264,7 +277,7 @@ func parseBLSEntry(entryPath, fsRoot string, variables map[string]string) (boot. var img boot.OSImage err = fmt.Errorf("neither linux, efi, nor multiboot present in BootLoaderSpec config") if _, ok := vals["linux"]; ok { - img, err = parseLinuxImage(vals, fsRoot, variables) + img, err = parseLinuxImage(vals, fsRoot, variables, grubDefaultFlag) } else if _, ok := vals["multiboot"]; ok { err = fmt.Errorf("multiboot not yet supported") } else if _, ok := vals["efi"]; ok { diff --git a/pkg/boot/bls/bls_test.go b/pkg/boot/bls/bls_test.go index a8e9831a91..b544808bd0 100644 --- a/pkg/boot/bls/bls_test.go +++ b/pkg/boot/bls/bls_test.go @@ -38,7 +38,7 @@ func DISABLEDTestGenerateConfigs(t *testing.T) { for _, test := range tests { configPath := strings.TrimSuffix(test, ".json") t.Run(configPath, func(t *testing.T) { - imgs, err := ScanBLSEntries(ulogtest.Logger{t}, configPath, nil) + imgs, err := ScanBLSEntries(ulogtest.Logger{t}, configPath, nil, "") if err != nil { t.Fatalf("Failed to parse %s: %v", test, err) } @@ -56,7 +56,7 @@ func TestParseBLSEntries(t *testing.T) { for _, tt := range blsEntries { t.Run(tt.entry, func(t *testing.T) { - image, err := parseBLSEntry(filepath.Join(dir, tt.entry), fsRoot, nil) + image, err := parseBLSEntry(filepath.Join(dir, tt.entry), fsRoot, nil, false) if err != nil { if tt.err == "" { t.Fatalf("Got error %v", err) @@ -89,7 +89,7 @@ func TestScanBLSEntries(t *testing.T) { t.Errorf("Failed to read test json '%v':%v", test, err) } - imgs, err := ScanBLSEntries(ulogtest.Logger{t}, configPath, nil) + imgs, err := ScanBLSEntries(ulogtest.Logger{t}, configPath, nil, "") if err != nil { t.Fatalf("Failed to parse %s: %v", test, err) } @@ -110,7 +110,7 @@ func TestSetBLSRank(t *testing.T) { for _, tt := range blsEntries { t.Run(tt.entry, func(t *testing.T) { - image, err := parseBLSEntry(filepath.Join(dir, tt.entry), fsRoot, nil) + image, err := parseBLSEntry(filepath.Join(dir, tt.entry), fsRoot, nil, false) if err != nil { if tt.err == "" { t.Fatalf("Got error %v", err) diff --git a/pkg/boot/grub/grub.go b/pkg/boot/grub/grub.go index d2bd9dfe74..5a9acde0c9 100644 --- a/pkg/boot/grub/grub.go +++ b/pkg/boot/grub/grub.go @@ -15,6 +15,7 @@ package grub import ( "context" + "errors" "fmt" "io" "log" @@ -43,6 +44,13 @@ var probeGrubFiles = []string{ "grub2/grub.cfg", "boot/grub2/grub.cfg", } +var probeGrubEnvFiles = []string{ + "EFI/*/grubenv", + "boot/grub/grubenv", + "grub/grubenv", + "grub2/grubenv", + "boot/grub2/grubenv", +} // Grub syntax for OpenSUSE/Fedora/RHEL has some undocumented quirks. You // won't find it on the master branch, but instead look at the rhel and fedora @@ -63,6 +71,8 @@ var anyEscape = regexp.MustCompile(`\\.{0,3}`) // mountFlags are the flags this grub interpreter uses to mount partitions. var mountFlags = uintptr(mount.ReadOnly) +var errMissingKey = errors.New("key is not found") + // absFileScheme creates a file:/// scheme with an absolute path. Technically, // file schemes must be absolute paths and Go makes that assumption. func absFileScheme(path string) (*url.URL, error) { @@ -113,11 +123,11 @@ func ParseLocalConfig(ctx context.Context, diskDir string, devices block.BlockDe return nil, fmt.Errorf("no valid grub config found") } -func grubScanBLSEntries(mountPool *mount.Pool, variables map[string]string) ([]boot.OSImage, error) { +func grubScanBLSEntries(mountPool *mount.Pool, variables map[string]string, grubDefaultSavedEntry string) ([]boot.OSImage, error) { var images []boot.OSImage // Scan each mounted partition for BLS entries for _, m := range mountPool.MountPoints { - imgs, _ := bls.ScanBLSEntries(ulog.Null, m.Path, variables) + imgs, _ := bls.ScanBLSEntries(ulog.Null, m.Path, variables, grubDefaultSavedEntry) images = append(images, imgs...) } if len(images) == 0 { @@ -126,6 +136,29 @@ func grubScanBLSEntries(mountPool *mount.Pool, variables map[string]string) ([]b return images, nil } +// Find and return the value of the key from a grubenv file +func findkeywordGrubEnv(file string, fsRoot string, key string) (string, error) { + FileGrubenv, err := filepath.Glob(filepath.Join(fsRoot, file)) + if FileGrubenv == nil || err != nil { + return "", err + } + relNamesFile, err := os.Open(FileGrubenv[0]) + if err != nil { + return "", fmt.Errorf("[grubenv]:%w", err) + } + grubenv, err := uio.ReadAll(relNamesFile) + if err != nil { + return "", fmt.Errorf("[grubenv]:%w", err) + } + for _, line := range strings.Split(string(grubenv), "\n") { + vals := strings.SplitN(line, "=", 2) + if vals[0] == key { + return vals[1], nil + } + } + return "", fmt.Errorf("%q:%w", key, errMissingKey) +} + // ParseConfigFile parses a grub configuration as specified in // https://www.gnu.org/software/grub/manual/grub/ // @@ -146,16 +179,31 @@ func ParseConfigFile(ctx context.Context, s curl.Schemes, configFile string, roo seenLinux := make(map[*boot.LinuxImage]struct{}) seenMB := make(map[*boot.MultibootImage]struct{}) + var grubDefaultSavedEntry string + // If the value of keyword "default_saved_entry" exists, find the value of "save_entry" from grubenv files from all possible paths. + if _, ok := p.variables["default_saved_entry"]; ok { + for _, m := range mountPool.MountPoints { + for _, file := range probeGrubEnvFiles { + // Parse grubenv and return the value of 'saved_entry'. + val, _ := findkeywordGrubEnv(file, m.Path, "saved_entry") + if val != "" { + grubDefaultSavedEntry = val + } + } + } + } + if defaultEntry, ok := p.variables["default"]; ok { p.labelOrder = append([]string{defaultEntry}, p.labelOrder...) } var images []boot.OSImage if p.blscfgFound { - if imgs, err := grubScanBLSEntries(p.mountPool, p.variables); err == nil { + if imgs, err := grubScanBLSEntries(p.mountPool, p.variables, grubDefaultSavedEntry); err == nil { images = append(images, imgs...) } } + for _, label := range p.labelOrder { if img, ok := p.linuxEntries[label]; ok { if _, ok := seenLinux[img]; !ok { @@ -456,7 +504,16 @@ func (c *parser) append(ctx context.Context, config string) error { if vals[0] == "root" { continue } - c.variables[vals[0]] = vals[1] + // here we only add the support for the case: set default="${saved_entry}". + if vals[0] == "default" { + if vals[1] == "${saved_entry}" { + c.variables["default_saved_entry"] = vals[1] + } else { + c.variables[vals[0]] = vals[1] + } + } else { + c.variables[vals[0]] = vals[1] + } } case "configfile": diff --git a/pkg/boot/grub/testdata_new/CentOS_8_Stream_x86_64_blscfg_sda1.json b/pkg/boot/grub/testdata_new/CentOS_8_Stream_x86_64_blscfg_sda1.json index 245ef4bebf..f564ae1fa1 100644 --- a/pkg/boot/grub/testdata_new/CentOS_8_Stream_x86_64_blscfg_sda1.json +++ b/pkg/boot/grub/testdata_new/CentOS_8_Stream_x86_64_blscfg_sda1.json @@ -9,7 +9,7 @@ "name": "testdata_new/CentOS_8_Stream_x86_64_blscfg_sda1/boot/vmlinuz-5.18.0" }, "name": "CentOS Linux (5.18.0) 8 5.18.0", - "rank": "1" + "rank": "2" }, { "cmdline": "root=UUID=d0be5cb9-622d-42d5-a531-674aaa120309 ro crashkernel=auto rhgb quiet console=ttyS1,57600n8", diff --git a/pkg/boot/localboot/localboot.go b/pkg/boot/localboot/localboot.go index 83fb9cfacf..e3895f1cce 100644 --- a/pkg/boot/localboot/localboot.go +++ b/pkg/boot/localboot/localboot.go @@ -28,7 +28,7 @@ func (a byRank) Len() int { return len(a) } // parse treats device as a block device with a file system. func parse(l ulog.Logger, device *block.BlockDev, devices block.BlockDevices, mountDir string, mountPool *mount.Pool) []boot.OSImage { - imgs, err := bls.ScanBLSEntries(l, mountDir, nil) + imgs, err := bls.ScanBLSEntries(l, mountDir, nil, "") if err != nil { l.Printf("No systemd-boot BootLoaderSpec configs found on %s, trying another format...: %v", device, err) }