From 2e9c85e63acf926ab9e0a00b3dabff4927c70c4b Mon Sep 17 00:00:00 2001 From: Itxaka Date: Wed, 21 Feb 2024 10:44:32 +0100 Subject: [PATCH] Add shim to choose next entry to boot from (#230) --- internal/agent/install.go | 2 +- main.go | 53 ++++-- pkg/action/bootentries.go | 245 ++++++++++++++++++++++++ pkg/action/bootentries_test.go | 258 ++++++++++++++++++++++++++ pkg/constants/constants.go | 11 ++ pkg/types/v1/syscall.go | 4 + pkg/types/v1/syscall_test.go | 10 + pkg/uki/reset.go | 7 + pkg/uki/upgrade.go | 8 + pkg/utils/common.go | 69 +++++++ pkg/utils/partitions/getpartitions.go | 20 ++ pkg/utils/utils_test.go | 74 ++++++++ tests/mocks/syscall_mock.go | 33 +++- 13 files changed, 775 insertions(+), 19 deletions(-) create mode 100644 pkg/action/bootentries.go create mode 100644 pkg/action/bootentries_test.go diff --git a/internal/agent/install.go b/internal/agent/install.go index 2e99cc01..32d129b6 100644 --- a/internal/agent/install.go +++ b/internal/agent/install.go @@ -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) diff --git a/main.go b/main.go index 91791c58..070fbf69 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/kairos-io/kairos-agent/v2/pkg/constants" "os" "path/filepath" "regexp" @@ -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 { @@ -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"), ) }, @@ -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 } @@ -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 } @@ -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 } @@ -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()...) }, }, { @@ -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: ` @@ -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 { @@ -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 } @@ -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() { diff --git a/pkg/action/bootentries.go b/pkg/action/bootentries.go new file mode 100644 index 00000000..3c2265ff --- /dev/null +++ b/pkg/action/bootentries.go @@ -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) +} diff --git a/pkg/action/bootentries_test.go b/pkg/action/bootentries_test.go new file mode 100644 index 00000000..e98d6455 --- /dev/null +++ b/pkg/action/bootentries_test.go @@ -0,0 +1,258 @@ +package action + +import ( + "bytes" + "github.com/jaypipes/ghw/pkg/block" + agentConfig "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" + v1mock "github.com/kairos-io/kairos-agent/v2/tests/mocks" + "github.com/kairos-io/kairos-sdk/collector" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" + "os" + "syscall" +) + +var _ = Describe("Bootentries tests", Label("bootentry"), func() { + var config *agentConfig.Config + var fs vfs.FS + var logger v1.Logger + var runner *v1mock.FakeRunner + var mounter *v1mock.ErrorMounter + var syscallMock *v1mock.FakeSyscall + var client *v1mock.FakeHTTPClient + var cloudInit *v1mock.FakeCloudInitRunner + var cleanup func() + var memLog *bytes.Buffer + var extractor *v1mock.FakeImageExtractor + var ghwTest v1mock.GhwMock + + BeforeEach(func() { + runner = v1mock.NewFakeRunner() + syscallMock = &v1mock.FakeSyscall{} + mounter = v1mock.NewErrorMounter() + client = &v1mock.FakeHTTPClient{} + memLog = &bytes.Buffer{} + logger = v1.NewBufferLogger(memLog) + extractor = v1mock.NewFakeImageExtractor(logger) + logger.SetLevel(v1.DebugLevel()) + var err error + fs, cleanup, err = vfst.NewTestFS(map[string]interface{}{}) + // Create proper dir structure for our EFI partition contentens + Expect(err).Should(BeNil()) + err = fsutils.MkdirAll(fs, "/efi/loader/entries", os.ModeDir|os.ModePerm) + Expect(err).Should(BeNil()) + err = fsutils.MkdirAll(fs, "/efi/EFI/BOOT", os.ModeDir|os.ModePerm) + Expect(err).Should(BeNil()) + err = fsutils.MkdirAll(fs, "/efi/EFI/kairos", os.ModeDir|os.ModePerm) + Expect(err).Should(BeNil()) + err = fsutils.MkdirAll(fs, "/etc/cos/", os.ModeDir|os.ModePerm) + Expect(err).Should(BeNil()) + err = fsutils.MkdirAll(fs, "/run/initramfs/cos-state/grub/", os.ModeDir|os.ModePerm) + Expect(err).Should(BeNil()) + err = fsutils.MkdirAll(fs, "/etc/kairos/branding/", os.ModeDir|os.ModePerm) + Expect(err).Should(BeNil()) + + cloudInit = &v1mock.FakeCloudInitRunner{} + config = agentConfig.NewConfig( + agentConfig.WithFs(fs), + agentConfig.WithRunner(runner), + agentConfig.WithLogger(logger), + agentConfig.WithMounter(mounter), + agentConfig.WithSyscall(syscallMock), + agentConfig.WithClient(client), + agentConfig.WithCloudInitRunner(cloudInit), + agentConfig.WithImageExtractor(extractor), + ) + config.Config = collector.Config{} + + mainDisk := block.Disk{ + Name: "device", + Partitions: []*block.Partition{ + { + Name: "device1", + FilesystemLabel: "COS_GRUB", + Type: "ext4", + MountPoint: "/efi", + }, + }, + } + ghwTest = v1mock.GhwMock{} + ghwTest.AddDisk(mainDisk) + ghwTest.CreateDevices() + }) + + AfterEach(func() { + cleanup() + }) + Context("Under Uki", func() { + BeforeEach(func() { + err := fs.Mkdir("/proc", os.ModeDir|os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/proc/cmdline", []byte("rd.immucore.uki"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + }) + Context("ListBootEntries", func() { + It("fails to list the boot entries when there is no loader.conf", func() { + err := ListBootEntries(config) + Expect(err).To(HaveOccurred()) + }) + }) + Context("ListSystemdEntries", func() { + It("lists the boot entries if there is any", func() { + err := fs.WriteFile("/efi/loader/loader.conf", []byte("timeout 5\ndefault kairos\nrecovery kairos2\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/efi/loader/entries/kairos.conf", []byte("title kairos\nlinux /vmlinuz\ninitrd /initrd\noptions root=LABEL=COS_GRUB\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + + err = fs.WriteFile("/efi/loader/entries/kairos2.conf", []byte("title kairos2\nlinux /vmlinuz2\ninitrd /initrd2\noptions root=LABEL=COS_GRUB2\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + + entries, err := listSystemdEntries(config, &v1.Partition{MountPoint: "/efi"}) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(HaveLen(2)) + Expect(entries).To(ContainElement("kairos.conf")) + Expect(entries).To(ContainElement("kairos2.conf")) + + }) + It("list empty boot entries if there is none", func() { + entries, err := listSystemdEntries(config, &v1.Partition{MountPoint: "/efi"}) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(HaveLen(0)) + + }) + }) + Context("SelectBootEntry", func() { + It("fails to select the boot entry if it doesnt exist", func() { + err := SelectBootEntry(config, "kairos") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + It("selects the boot entry", func() { + err := fs.WriteFile("/efi/loader/entries/kairos.conf", []byte("title kairos\nlinux /vmlinuz\ninitrd /initrd\noptions root=LABEL=COS_GRUB\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/efi/loader/entries/kairos2.conf", []byte("title kairos\nlinux /vmlinuz\ninitrd /initrd\noptions root=LABEL=COS_GRUB\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/efi/loader/loader.conf", []byte(""), os.ModePerm) + + err = SelectBootEntry(config, "kairos.conf") + Expect(err).ToNot(HaveOccurred()) + Expect(memLog.String()).To(ContainSubstring("Default boot entry set to kairos")) + reader, err := utils.SystemdBootConfReader(fs, "/efi/loader/loader.conf") + Expect(err).ToNot(HaveOccurred()) + Expect(reader["default"]).To(Equal("kairos.conf")) + // Should have called a remount to make it RW + Expect(syscallMock.WasMountCalledWith( + "", + "/efi", + "", + syscall.MS_REMOUNT, + "")).To(BeTrue()) + // Should have called a remount to make it RO + Expect(syscallMock.WasMountCalledWith( + "", + "/efi", + "", + syscall.MS_REMOUNT|syscall.MS_RDONLY, + "")).To(BeTrue()) + }) + It("selects the boot entry with the missing .conf extension", func() { + err := fs.WriteFile("/efi/loader/entries/kairos.conf", []byte("title kairos\nlinux /vmlinuz\ninitrd /initrd\noptions root=LABEL=COS_GRUB\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/efi/loader/entries/kairos2.conf", []byte("title kairos\nlinux /vmlinuz\ninitrd /initrd\noptions root=LABEL=COS_GRUB\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/efi/loader/loader.conf", []byte(""), os.ModePerm) + + err = SelectBootEntry(config, "kairos2") + Expect(err).ToNot(HaveOccurred()) + Expect(memLog.String()).To(ContainSubstring("Default boot entry set to kairos2")) + reader, err := utils.SystemdBootConfReader(fs, "/efi/loader/loader.conf") + Expect(err).ToNot(HaveOccurred()) + Expect(reader["default"]).To(Equal("kairos2.conf")) + + // Should have called a remount to make it RW + Expect(syscallMock.WasMountCalledWith( + "", + "/efi", + "", + syscall.MS_REMOUNT, + "")).To(BeTrue()) + // Should have called a remount to make it RO + Expect(syscallMock.WasMountCalledWith( + "", + "/efi", + "", + syscall.MS_REMOUNT|syscall.MS_RDONLY, + "")).To(BeTrue()) + }) + }) + }) + Context("Under grub", func() { + Context("ListBootEntries", func() { + It("fails to list the boot entries when there is no grub files", func() { + err := ListBootEntries(config) + Expect(err).To(HaveOccurred()) + }) + }) + Context("ListSystemdEntries", func() { + It("lists the boot entries if there is any", func() { + err := fs.WriteFile("/etc/cos/grub.cfg", []byte("whatever whatever --id kairos {"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/run/initramfs/cos-state/grub/grub.cfg", []byte("whatever whatever --id kairos2 {"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/etc/kairos/branding/grubmenu.cfg", []byte("whatever whatever --id kairos3 {"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + + entries, err := listGrubEntries(config) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(HaveLen(3)) + Expect(entries).To(ContainElement("kairos")) + Expect(entries).To(ContainElement("kairos2")) + Expect(entries).To(ContainElement("kairos3")) + + }) + It("list empty boot entries if there is none", func() { + entries, err := listGrubEntries(config) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(HaveLen(0)) + + }) + }) + Context("SelectBootEntry", func() { + BeforeEach(func() { + runner.SideEffect = func(cmd string, args ...string) ([]byte, error) { + switch cmd { + case "grub2-editenv": + return []byte(""), nil + default: + return []byte{}, nil + } + } + }) + It("fails to select the boot entry if it doesnt exist", func() { + err := SelectBootEntry(config, "kairos") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not exist")) + }) + It("selects the boot entry", func() { + err := fs.WriteFile("/etc/cos/grub.cfg", []byte("whatever whatever --id kairos {"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/run/initramfs/cos-state/grub/grub.cfg", []byte("whatever whatever --id kairos2 {"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/etc/kairos/branding/grubmenu.cfg", []byte("whatever whatever --id kairos3 {"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + + err = SelectBootEntry(config, "kairos") + Expect(err).ToNot(HaveOccurred()) + Expect(runner.IncludesCmds([][]string{ + {"grub2-editenv", "/oem/grubenv", "set", "next_entry=kairos"}, + })).ToNot(HaveOccurred()) + Expect(memLog.String()).To(ContainSubstring("Default boot entry set to kairos")) + }) + }) + }) +}) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 04dfda2e..ad5b5e76 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -149,3 +149,14 @@ func GetGrubFonts() []string { func GetGrubModules() []string { return []string{"loopback.mod", "squash4.mod", "xzio.mod", "gzio.mod", "regexp.mod"} } + +func GetConfigScanDirs() []string { + return []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 + } +} diff --git a/pkg/types/v1/syscall.go b/pkg/types/v1/syscall.go index 2a6a1ce4..e6a9ac15 100644 --- a/pkg/types/v1/syscall.go +++ b/pkg/types/v1/syscall.go @@ -23,6 +23,7 @@ import ( type SyscallInterface interface { Chroot(string) error Chdir(string) error + Mount(string, string, string, uintptr, string) error } type RealSyscall struct{} @@ -34,3 +35,6 @@ func (r *RealSyscall) Chroot(path string) error { func (r *RealSyscall) Chdir(path string) error { return syscall.Chdir(path) } +func (r *RealSyscall) Mount(source string, target string, fstype string, flags uintptr, data string) error { + return syscall.Mount(source, target, fstype, flags, data) +} diff --git a/pkg/types/v1/syscall_test.go b/pkg/types/v1/syscall_test.go index 6696084e..f18d7a60 100644 --- a/pkg/types/v1/syscall_test.go +++ b/pkg/types/v1/syscall_test.go @@ -44,4 +44,14 @@ var _ = Describe("Syscall", Label("types", "syscall"), func() { // We need elevated privs to chroot so this should fail Expect(err).To(BeNil()) }) + It("Calling mount on the fake syscall should not fail", func() { + r := v1mock.FakeSyscall{} + err := r.Mount("source", "target", "fstype", 0, "data") + Expect(err).To(BeNil()) + }) + It("Calling mount on the real syscall fail (wrong args)", func() { + r := v1.RealSyscall{} + err := r.Mount("source", "target", "fstype", 0, "data") + Expect(err).To(HaveOccurred()) + }) }) diff --git a/pkg/uki/reset.go b/pkg/uki/reset.go index 40db03e6..2dff04a0 100644 --- a/pkg/uki/reset.go +++ b/pkg/uki/reset.go @@ -3,6 +3,7 @@ package uki import ( "fmt" + "github.com/kairos-io/kairos-agent/v2/pkg/action" "github.com/kairos-io/kairos-agent/v2/pkg/config" "github.com/kairos-io/kairos-agent/v2/pkg/constants" "github.com/kairos-io/kairos-agent/v2/pkg/elemental" @@ -70,6 +71,12 @@ func (r *ResetAction) Run() (err error) { if err != nil { return fmt.Errorf("copying recovery to active: %w", err) } + // SelectBootEntry sets the default boot entry to the selected entry + err = action.SelectBootEntry(r.cfg, "active") + // Should we fail? Or warn? + if err != nil { + return err + } _ = elementalUtils.RunStage(r.cfg, "kairos-uki-reset.after") _ = events.RunHookScript("/usr/bin/kairos-agent.uki.reset.after.hook") //nolint:errcheck diff --git a/pkg/uki/upgrade.go b/pkg/uki/upgrade.go index 794464c6..ccd13ce2 100644 --- a/pkg/uki/upgrade.go +++ b/pkg/uki/upgrade.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/kairos-io/kairos-agent/v2/pkg/action" "github.com/kairos-io/kairos-agent/v2/pkg/config" "github.com/kairos-io/kairos-agent/v2/pkg/constants" "github.com/kairos-io/kairos-agent/v2/pkg/elemental" @@ -77,6 +78,13 @@ func (i *UpgradeAction) Run() (err error) { return fmt.Errorf("removing artifact set: %w", err) } + // SelectBootEntry sets the default boot entry to the selected entry + err = action.SelectBootEntry(i.cfg, "active") + // Should we fail? Or warn? + if err != nil { + return err + } + _ = elementalUtils.RunStage(i.cfg, "kairos-uki-upgrade.after") _ = events.RunHookScript("/usr/bin/kairos-agent.uki.upgrade.after.hook") //nolint:errcheck diff --git a/pkg/utils/common.go b/pkg/utils/common.go index a6a8f021..d8a1d21c 100644 --- a/pkg/utils/common.go +++ b/pkg/utils/common.go @@ -17,6 +17,7 @@ limitations under the License. package utils import ( + "bufio" "crypto/sha256" "errors" "fmt" @@ -510,6 +511,17 @@ func IsUki() bool { return false } +// IsUkiWithFs checks if the system is running in UKI mode +// by checking the kernel command line for the rd.immucore.uki flag +// Uses a v1.Fs interface to allow for testing +func IsUkiWithFs(fs v1.FS) bool { + cmdline, _ := fs.ReadFile("/proc/cmdline") + if strings.Contains(string(cmdline), "rd.immucore.uki") { + return true + } + return false +} + const ( UkiHDD state.Boot = "uki_boot_mode" UkiRemovableMedia state.Boot = "uki_install_mode" @@ -528,3 +540,60 @@ func UkiBootMode() state.Boot { } return state.Unknown } + +// SystemdBootConfReader reads a systemd-boot conf file and returns a map with the key/value pairs +// TODO: Move this to the sdk with the FS interface +func SystemdBootConfReader(fs v1.FS, filePath string) (map[string]string, error) { + file, err := fs.Open(filePath) + if err != nil { + return nil, err + } + defer func(file *os.File) { + _ = file.Close() + }(file) + + result := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, " ", 2) + if len(parts) == 2 { + result[parts[0]] = parts[1] + } + if len(parts) == 1 { + result[parts[0]] = "" + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// SystemdBootConfWriter writes a map to a systemd-boot conf file +// TODO: Move this to the sdk with the FS interface +func SystemdBootConfWriter(fs v1.FS, filePath string, conf map[string]string) error { + file, err := fs.Create(filePath) + if err != nil { + return err + } + defer func(file *os.File) { + _ = file.Close() + }(file) + + writer := bufio.NewWriter(file) + for k, v := range conf { + if v == "" { + _, err = writer.WriteString(fmt.Sprintf("%s \n", k)) + } else { + _, err = writer.WriteString(fmt.Sprintf("%s %s\n", k, v)) + } + if err != nil { + return err + } + } + + return writer.Flush() +} diff --git a/pkg/utils/partitions/getpartitions.go b/pkg/utils/partitions/getpartitions.go index d43fc52e..f33638e9 100644 --- a/pkg/utils/partitions/getpartitions.go +++ b/pkg/utils/partitions/getpartitions.go @@ -27,6 +27,7 @@ import ( "github.com/jaypipes/ghw/pkg/context" "github.com/jaypipes/ghw/pkg/linuxpath" ghwUtil "github.com/jaypipes/ghw/pkg/util" + "github.com/kairos-io/kairos-agent/v2/pkg/constants" v1 "github.com/kairos-io/kairos-agent/v2/pkg/types/v1" log "github.com/sirupsen/logrus" ) @@ -209,3 +210,22 @@ func GetPartitionViaDM(fs v1.FS, label string) *v1.Partition { } return part } + +// GetEfiPartition returns the EFI partition by looking for the partition with the label "COS_GRUB" +func GetEfiPartition() (*v1.Partition, error) { + var efiPartition *v1.Partition + parts, err := GetAllPartitions() + if err != nil { + return efiPartition, fmt.Errorf("could not read host partitions") + } + for _, p := range parts { + if p.FilesystemLabel == constants.EfiLabel { + efiPartition = p + break + } + } + if efiPartition == nil { + return efiPartition, fmt.Errorf("could not find EFI partition") + } + return efiPartition, nil +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index e45c2796..be7e6f1e 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -1071,4 +1071,78 @@ var _ = Describe("Utils", Label("utils"), func() { Expect(err.Error()).To(ContainSubstring("Cleanup error 3")) }) }) + Describe("GetEfiPartition", func() { + var ghwTest v1mock.GhwMock + + BeforeEach(func() { + mainDisk := block.Disk{ + Name: "device", + Partitions: []*block.Partition{ + { + Name: "device1", + FilesystemLabel: "COS_GRUB", + Type: "ext4", + MountPoint: "/efi", + }, + }, + } + ghwTest = v1mock.GhwMock{} + ghwTest.AddDisk(mainDisk) + ghwTest.CreateDevices() + }) + It("returns the efi partition", func() { + efi, err := partitions.GetEfiPartition() + Expect(err).ToNot(HaveOccurred()) + Expect(efi.FilesystemLabel).To(Equal("COS_GRUB")) + Expect(efi.Name).To(Equal("device1")) // Just to make sure its our mocked system + }) + It("fails to find the efi partition", func() { + ghwTest.Clean() // Remove the disk + efi, err := partitions.GetEfiPartition() + Expect(err).To(HaveOccurred()) + Expect(efi).To(BeNil()) + }) + }) + Describe("SystemdBootConfWriter/SystemdBootConfReader", func() { + BeforeEach(func() { + err := fsutils.MkdirAll(fs, "/efi/loader/entries", os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + }) + It("writes the conf file with proper attrs", func() { + conf := map[string]string{ + "timeout": "5", + "default": "kairos", + "empty": "", + } + err := utils.SystemdBootConfWriter(fs, "/efi/loader/entries/test1.conf", conf) + Expect(err).ToNot(HaveOccurred()) + reader, err := utils.SystemdBootConfReader(fs, "/efi/loader/entries/test1.conf") + Expect(err).ToNot(HaveOccurred()) + Expect(reader["timeout"]).To(Equal("5")) + Expect(reader["default"]).To(Equal("kairos")) + Expect(reader["recovery"]).To(Equal("")) + }) + It("reads the conf file with proper k,v attrs", func() { + err := fs.WriteFile("/efi/loader/entries/test2.conf", []byte("timeout 5\ndefault kairos\nrecovery\n"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + reader, err := utils.SystemdBootConfReader(fs, "/efi/loader/entries/test2.conf") + Expect(err).ToNot(HaveOccurred()) + Expect(reader["timeout"]).To(Equal("5")) + Expect(reader["default"]).To(Equal("kairos")) + Expect(reader["recovery"]).To(Equal("")) + }) + + }) + Describe("IsUkiWithFs", func() { + It("returns true if rd.immucore.uki is present", func() { + err := fs.Mkdir("/proc", os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + err = fs.WriteFile("/proc/cmdline", []byte("rd.immucore.uki"), os.ModePerm) + Expect(err).ToNot(HaveOccurred()) + Expect(utils.IsUkiWithFs(fs)).To(BeTrue()) + }) + It("returns false if rd.immucore.uki is not present", func() { + Expect(utils.IsUkiWithFs(fs)).To(BeFalse()) + }) + }) }) diff --git a/tests/mocks/syscall_mock.go b/tests/mocks/syscall_mock.go index 2ede1f4c..a232bea9 100644 --- a/tests/mocks/syscall_mock.go +++ b/tests/mocks/syscall_mock.go @@ -16,13 +16,24 @@ limitations under the License. package mocks -import "errors" +import ( + "errors" +) // FakeSyscall is a test helper method to track calls to syscall // It can also fail on Chroot command type FakeSyscall struct { chrootHistory []string // Track calls to chroot ErrorOnChroot bool + mounts []FakeMount +} + +type FakeMount struct { + Source string + Target string + Fstype string + Flags uintptr + Data string } // Chroot will store the chroot call @@ -48,3 +59,23 @@ func (f *FakeSyscall) WasChrootCalledWith(path string) bool { } return false } + +func (f *FakeSyscall) Mount(source string, target string, fstype string, flags uintptr, data string) error { + f.mounts = append(f.mounts, FakeMount{ + Source: source, + Target: target, + Fstype: fstype, + Flags: flags, + Data: data, + }) + return nil +} + +func (f *FakeSyscall) WasMountCalledWith(source string, target string, fstype string, flags uintptr, data string) bool { + for _, m := range f.mounts { + if m.Source == source && m.Target == target && m.Fstype == fstype && m.Flags == flags && m.Data == data { + return true + } + } + return false +}