Skip to content

Commit

Permalink
Add shim to choose next entry to boot from (#230)
Browse files Browse the repository at this point in the history
  • Loading branch information
Itxaka authored Feb 21, 2024
1 parent cce4321 commit 2e9c85e
Show file tree
Hide file tree
Showing 13 changed files with 775 additions and 19 deletions.
2 changes: 1 addition & 1 deletion internal/agent/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ func RunInstall(c *config.Config) error {

// UKI path. Check if we are on UKI AND if we are running off a cd, otherwise it makes no sense to run the install
// From the installed system
if internalutils.IsUki() {
if internalutils.IsUkiWithFs(c.Fs) {
c.Logger.Debugf("UKI mode: %s\n", internalutils.UkiBootMode())
if internalutils.UkiBootMode() == internalutils.UkiRemovableMedia {
return runInstallUki(c)
Expand Down
53 changes: 36 additions & 17 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/kairos-io/kairos-agent/v2/pkg/constants"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -36,15 +37,6 @@ import (
"gopkg.in/yaml.v3"
)

var configScanDir = []string{
"/oem",
"/system/oem",
"/usr/local/cloud-config",
"/run/initramfs/live",
"/etc/kairos", // Default system configuration file https://github.com/kairos-io/kairos/issues/2221
"/etc/elemental", // for backwards compatibility
}

// ReleasesToOutput gets a semver.Collection and outputs it in the given format
// Only used here.
func ReleasesToOutput(rels []string, output string) []string {
Expand Down Expand Up @@ -186,7 +178,7 @@ See https://kairos.io/docs/upgrade/manual/ for documentation.
}

return agent.Upgrade(source, c.Bool("force"),
c.Bool("strict-validation"), configScanDir,
c.Bool("strict-validation"), constants.GetConfigScanDirs(),
c.Bool("pre"), c.Bool("recovery"),
)
},
Expand Down Expand Up @@ -330,7 +322,7 @@ E.g. kairos-agent install-bundle container:quay.io/kairos/kairos...
Description: "Show the runtime configuration of the machine. It will scan the machine for all the configuration and will return the config file processed and found.",
Aliases: []string{},
Action: func(c *cli.Context) error {
config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs)
config, err := agentConfig.Scan(collector.Directories(constants.GetConfigScanDirs()...), collector.NoLogs)
if err != nil {
return err
}
Expand Down Expand Up @@ -359,7 +351,7 @@ enabled: true`,
Description: "It allows to navigate the YAML config file by searching with 'yq' style keywords as `config get k3s` to retrieve the k3s config block",
Aliases: []string{"g"},
Action: func(c *cli.Context) error {
config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs, collector.StrictValidation(c.Bool("strict-validation")))
config, err := agentConfig.Scan(collector.Directories(constants.GetConfigScanDirs()...), collector.NoLogs, collector.StrictValidation(c.Bool("strict-validation")))
if err != nil {
return err
}
Expand Down Expand Up @@ -430,7 +422,7 @@ enabled: true`,
},
Action: func(c *cli.Context) error {

config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs, collector.StrictValidation(c.Bool("strict-validation")))
config, err := agentConfig.Scan(collector.Directories(constants.GetConfigScanDirs()...), collector.NoLogs, collector.StrictValidation(c.Bool("strict-validation")))
if err != nil {
return err
}
Expand Down Expand Up @@ -540,7 +532,7 @@ This command is meant to be used from the boot GRUB menu, but can be started man
Action: func(c *cli.Context) error {
source := c.String("source")

return agent.Install(source, configScanDir...)
return agent.Install(source, constants.GetConfigScanDirs()...)
},
},
{
Expand Down Expand Up @@ -584,7 +576,7 @@ This command is meant to be used from the boot GRUB menu, but can likely be used
unattended := c.Bool("unattended")
resetOem := c.Bool("reset-oem")

return agent.Reset(reboot, unattended, resetOem, configScanDir...)
return agent.Reset(reboot, unattended, resetOem, constants.GetConfigScanDirs()...)
},
Usage: "Starts kairos reset mode",
Description: `
Expand Down Expand Up @@ -663,7 +655,7 @@ The validate command expects a configuration file as its only argument. Local fi
},
Action: func(c *cli.Context) error {
stage := c.Args().First()
config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs)
config, err := agentConfig.Scan(collector.Directories(constants.GetConfigScanDirs()...), collector.NoLogs)
config.Strict = c.Bool("strict")

if len(c.StringSlice("cloud-init-paths")) > 0 {
Expand Down Expand Up @@ -706,7 +698,7 @@ The validate command expects a configuration file as its only argument. Local fi
if err != nil {
return fmt.Errorf("invalid path %s", destination)
}
config, err := agentConfig.Scan(collector.Directories(configScanDir...), collector.NoLogs)
config, err := agentConfig.Scan(collector.Directories(constants.GetConfigScanDirs()...), collector.NoLogs)
if err != nil {
return err
}
Expand Down Expand Up @@ -745,6 +737,33 @@ The validate command expects a configuration file as its only argument. Local fi
Description: "versioneer subcommands",
Subcommands: versioneer.CliCommands(),
},
{
Name: "bootentry",
Usage: "bootentry [--select]",
Description: "bootentry subcommands",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "select",
Usage: "Select the boot entry",
Aliases: []string{"s"},
},
},
Before: func(c *cli.Context) error {
return checkRoot()
},
Action: func(c *cli.Context) error {
cfg, err := agentConfig.Scan(collector.Directories(constants.GetConfigScanDirs()...), collector.NoLogs)
if err != nil {
return err
}
s := c.String("select")
// If we got a selection just go for it, otherwise enter an interactive mode to show entries and let user choose one
if s != "" {
return action.SelectBootEntry(cfg, s)
}
return action.ListBootEntries(cfg)
},
},
}

func main() {
Expand Down
245 changes: 245 additions & 0 deletions pkg/action/bootentries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package action

import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"

"github.com/erikgeiser/promptkit/confirmation"
"github.com/erikgeiser/promptkit/selection"
"github.com/kairos-io/kairos-agent/v2/pkg/config"
v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1"
"github.com/kairos-io/kairos-agent/v2/pkg/utils"
fsutils "github.com/kairos-io/kairos-agent/v2/pkg/utils/fs"
"github.com/kairos-io/kairos-agent/v2/pkg/utils/partitions"
)

// SelectBootEntry sets the default boot entry to the selected entry
// This is the entrypoint for the bootentry action with --select flag
// also other actions can call this function to set the default boot entry
func SelectBootEntry(cfg *config.Config, entry string) error {
if utils.IsUkiWithFs(cfg.Fs) {
return selectBootEntrySystemd(cfg, entry)
} else {
return selectBootEntryGrub(cfg, entry)
}
}

// ListBootEntries lists the boot entries available in the system and prompts the user to select one
// then calls the underlying SelectBootEntry function to mange the entry writing and validation
func ListBootEntries(cfg *config.Config) error {
if utils.IsUkiWithFs(cfg.Fs) {
return listBootEntriesSystemd(cfg)
} else {
return listBootEntriesGrub(cfg)
}
}

// selectBootEntryGrub sets the default boot entry to the selected entry via `grub2-editenv /oem/grubenv set next_entry=entry`
// also validates that the entry exists in our list of entries
func selectBootEntryGrub(cfg *config.Config, entry string) error {
// Validate if entry exists
entries, err := listGrubEntries(cfg)
if err != nil {
return err
}
// Check that entry exists in the entries list
err = entryInList(cfg, entry, entries)
if err != nil {
return err
}
cfg.Logger.Infof("Setting default boot entry to %s", entry)
// Set the default entry to the selected entry via `grub2-editenv /oem/grubenv set next_entry=statereset`
out, err := cfg.Runner.Run("grub2-editenv", "/oem/grubenv", "set", fmt.Sprintf("next_entry=%s", entry))
if err != nil {
cfg.Logger.Errorf("could not set default boot entry: %s\noutput: %s", err, out)
return err
}
cfg.Logger.Infof("Default boot entry set to %s", entry)
return err
}

// selectBootEntrySystemd sets the default boot entry to the selected entry via modifying the loader.conf file
// also validates that the entry exists in our list of entries
func selectBootEntrySystemd(cfg *config.Config, entry string) error {
// Read the systemd-boot conf file
if !strings.HasSuffix(entry, ".conf") {
entry = entry + ".conf"
}

cfg.Logger.Infof("Setting default boot entry to %s", entry)

// Get EFI partition
efiPartition, err := partitions.GetEfiPartition()
if err != nil {
return err
}
// Validate entry exists
entries, err := listSystemdEntries(cfg, efiPartition)
if err != nil {
return err

}
// Check that entry exists in the entries list
err = entryInList(cfg, entry, entries)
if err != nil {
return err
}

// Mount it RW
err = cfg.Syscall.Mount("", efiPartition.MountPoint, "", syscall.MS_REMOUNT, "")
if err != nil {
cfg.Logger.Errorf("could not remount EFI partition: %s", err)
return err
}
// Remount it RO when finished
defer func(source string, target string, fstype string, flags uintptr, data string) {
err = cfg.Syscall.Mount(source, target, fstype, flags, data)
if err != nil {
cfg.Logger.Errorf("could not remount EFI partition as RO: %s", err)
}
}("", efiPartition.MountPoint, "", syscall.MS_REMOUNT|syscall.MS_RDONLY, "")

systemdConf, err := utils.SystemdBootConfReader(cfg.Fs, filepath.Join(efiPartition.MountPoint, "loader/loader.conf"))
if err != nil {
cfg.Logger.Errorf("could not read loader.conf: %s", err)
return err
}
// Set the default entry to the selected entry
systemdConf["default"] = entry
err = utils.SystemdBootConfWriter(cfg.Fs, filepath.Join(efiPartition.MountPoint, "loader/loader.conf"), systemdConf)
if err != nil {
cfg.Logger.Errorf("could not write loader.conf: %s", err)
return err
}
cfg.Logger.Infof("Default boot entry set to %s", entry)
return err
}

// listBootEntriesGrub lists the boot entries available in the grub config files
// and prompts the user to select one
// then calls the underlying SelectBootEntry function to mange the entry writing and validation
func listBootEntriesGrub(cfg *config.Config) error {
entries, err := listGrubEntries(cfg)
if err != nil {
return err
}
// create a selector
selector := selection.New("Select Next Boot Entry", entries)
selector.Filter = nil // Remove the filter
selector.ResultTemplate = `` // Do not print the result as we are asking for confirmation afterwards
selected, _ := selector.RunPrompt()
c := confirmation.New("Are you sure you want to change the boot entry to "+selected, confirmation.Yes)
c.ResultTemplate = ``
confirm, err := c.RunPrompt()
if confirm {
return SelectBootEntry(cfg, selected)
}
return err
}

// listBootEntriesSystemd lists the boot entries available in the systemd-boot config files
// and prompts the user to select one
// then calls the underlying SelectBootEntry function to mange the entry writing and validation
func listBootEntriesSystemd(cfg *config.Config) error {
// Get EFI partition
efiPartition, err := partitions.GetEfiPartition()
if err != nil {
return err
}
// Get default entry from loader.conf
loaderConf, err := utils.SystemdBootConfReader(cfg.Fs, filepath.Join(efiPartition.MountPoint, "loader/loader.conf"))
if err != nil {
return err
}

entries, err := listSystemdEntries(cfg, efiPartition)

// create a selector
selector := selection.New(fmt.Sprintf("Select Boot Entry (current entry: %s)", loaderConf["default"]), entries)
selector.Filter = nil // Remove the filter
selector.ResultTemplate = `` // Do not print the result as we are asking for confirmation afterwards
selected, _ := selector.RunPrompt()
c := confirmation.New("Are you sure you want to change the boot entry to "+selected, confirmation.Yes)
c.ResultTemplate = ``
confirm, err := c.RunPrompt()
if confirm {
return SelectBootEntry(cfg, selected)
}
return err
}

// ListSystemdEntries reads the systemd-boot entries and returns a list of entries found
func listSystemdEntries(cfg *config.Config, efiPartition *v1.Partition) ([]string, error) {
var entries []string
err := fsutils.WalkDirFs(cfg.Fs, filepath.Join(efiPartition.MountPoint, "loader/entries/"), func(path string, info os.DirEntry, err error) error {
cfg.Logger.Debugf("Checking file %s", path)
if info == nil {
return nil
}
if info.IsDir() {
return nil
}
if filepath.Ext(info.Name()) != ".conf" {
return nil
}
entries = append(entries, info.Name())
return nil
})
return entries, err
}

// listGrubEntries reads the grub config files and returns a list of entries found
func listGrubEntries(cfg *config.Config) ([]string, error) {
// Read grub config from 3 places
// /etc/cos/grub.cfg
// /run/initramfs/cos-state/grub/grub.cfg
// /etc/kairos/branding/grubmenu.cfg
// And grep the entries by checking the --id\s([A-z0-9]*)\s{ pattern
var entries []string
for _, file := range []string{"/etc/cos/grub.cfg", "/run/initramfs/cos-state/grub/grub.cfg", "/etc/kairos/branding/grubmenu.cfg"} {
f, err := cfg.Fs.ReadFile(file)
if err != nil {
cfg.Logger.Errorf("could not read file %s: %s", file, err)
continue
}
re, _ := regexp.Compile(`--id\s([A-z0-9]*)\s{`)
matches := re.FindAllStringSubmatch(string(f), -1)
for _, match := range matches {
entries = append(entries, match[1])
}
}
entries = uniqueStringArray(entries)
return entries, nil
}

// I lost count on how many places I had to implement this function
// Is that difficult to provide a simple []string.Unique() method from the standard lib????
// Or at least a Set type?
func uniqueStringArray(arr []string) []string {
unique := make(map[string]bool)
var result []string
for _, s := range arr {
if !unique[s] {
unique[s] = true
result = append(result, s)
}
}
return result
}

// Another one. Seriously there is nothing to check if something is in a list?
func entryInList(cfg *config.Config, entry string, list []string) error {
// Check that entry exists in the entries list
for _, e := range list {
if e == entry {
return nil
}
}
cfg.Logger.Errorf("entry %s does not exist", entry)
cfg.Logger.Debugf("entries: %v", list)
return fmt.Errorf("entry %s does not exist", entry)
}
Loading

0 comments on commit 2e9c85e

Please sign in to comment.