diff --git a/cmd/installer/pkg/install/install.go b/cmd/installer/pkg/install/install.go index af09d1bc9..5378fb9b6 100644 --- a/cmd/installer/pkg/install/install.go +++ b/cmd/installer/pkg/install/install.go @@ -83,8 +83,8 @@ type Installer struct { bootPartitionFound bool - Current string - Next string + Current grub.BootLabel + Next grub.BootLabel } // NewInstaller initializes and returns an Installer. @@ -92,17 +92,13 @@ func NewInstaller(cmdline *procfs.Cmdline, seq runtime.Sequence, opts *Options) i = &Installer{ cmdline: cmdline, options: opts, - bootloader: &grub.Grub{ - BootDisk: opts.Disk, - Arch: opts.Arch, - }, } if err = i.probeBootPartition(); err != nil { return nil, err } - i.manifest, err = NewManifest(i.Next, seq, i.bootPartitionFound, i.options) + i.manifest, err = NewManifest(string(i.Next), seq, i.bootPartitionFound, i.options) if err != nil { return nil, fmt.Errorf("failed to create installation manifest: %w", err) } @@ -152,10 +148,25 @@ func (i *Installer) probeBootPartition() error { } } - var err error + grubConf, err := grub.Read(grub.ConfigPath) + if err != nil { + return err + } - // anyways run the Labels() to get the defaults initialized - i.Current, i.Next, err = i.bootloader.Labels() + next := grub.BootA + + if grubConf != nil { + i.Current = grubConf.Default + + next, err = grub.FlipBootLabel(grubConf.Default) + if err != nil { + return err + } + + i.bootloader = grubConf + } + + i.Next = next return err } @@ -262,32 +273,29 @@ func (i *Installer) Install(seq runtime.Sequence) (err error) { return nil } - i.cmdline.Append("initrd", filepath.Join("/", i.Next, constants.InitramfsAsset)) + i.cmdline.Append("initrd", filepath.Join("/", string(i.Next), constants.InitramfsAsset)) - grubcfg := &grub.Cfg{ - Default: i.Next, - Labels: []*grub.Label{ - { - Root: i.Next, - Initrd: filepath.Join("/", i.Next, constants.InitramfsAsset), - Kernel: filepath.Join("/", i.Next, constants.KernelAsset), - Append: i.cmdline.String(), - }, - }, + var conf *grub.Config + if i.bootloader == nil { + conf = grub.NewConfig(i.cmdline.String()) + } else { + existingConf, ok := i.bootloader.(*grub.Config) + if !ok { + return fmt.Errorf("unsupported bootloader type: %T", i.bootloader) + } + if err = existingConf.Put(i.Next, i.cmdline.String()); err != nil { + return err + } + existingConf.Default = i.Next + existingConf.Fallback = i.Current + + conf = existingConf } - if i.Current != "" { - grubcfg.Fallback = i.Current + i.bootloader = conf - grubcfg.Labels = append(grubcfg.Labels, &grub.Label{ - Root: i.Current, - Initrd: filepath.Join("/", i.Current, constants.InitramfsAsset), - Kernel: filepath.Join("/", i.Current, constants.KernelAsset), - Append: procfs.ProcCmdline().String(), - }) - } - - if err = i.bootloader.Install(i.Current, grubcfg, seq); err != nil { + err = i.bootloader.Install(i.options.Disk, i.options.Arch) + if err != nil { return err } @@ -316,7 +324,7 @@ func (i *Installer) Install(seq runtime.Sequence) (err error) { //nolint:errcheck defer meta.Close() - if ok := meta.LegacyADV.SetTag(adv.Upgrade, i.Current); !ok { + if ok := meta.LegacyADV.SetTag(adv.Upgrade, string(i.Current)); !ok { return fmt.Errorf("failed to set upgrade tag: %q", i.Current) } diff --git a/internal/app/machined/internal/server/v1alpha1/v1alpha1_server.go b/internal/app/machined/internal/server/v1alpha1/v1alpha1_server.go index 69538c914..6e80d4646 100644 --- a/internal/app/machined/internal/server/v1alpha1/v1alpha1_server.go +++ b/internal/app/machined/internal/server/v1alpha1/v1alpha1_server.go @@ -289,20 +289,27 @@ func (s *Server) Rollback(ctx context.Context, in *machine.RollbackRequest) (*ma return fmt.Errorf("boot disk not found") } - grub := &grub.Grub{ - BootDisk: disk.Device().Name(), - } - - _, next, err := grub.Labels() + conf, err := grub.Read(grub.ConfigPath) if err != nil { return err } - if _, err = os.Stat(filepath.Join(constants.BootMountPoint, next)); errors.Is(err, os.ErrNotExist) { + if conf == nil { + return fmt.Errorf("grub configuration not found, nothing to rollback") + } + + next, err := grub.FlipBootLabel(conf.Default) + if err != nil { + return err + } + + if _, err = os.Stat(filepath.Join(constants.BootMountPoint, string(next))); errors.Is(err, os.ErrNotExist) { return fmt.Errorf("cannot rollback to %q, label does not exist", next) } - if err := grub.Default(next); err != nil { + conf.Default = next + conf.Fallback = "" + if err := conf.Write(grub.ConfigPath); err != nil { return fmt.Errorf("failed to revert bootloader: %v", err) } diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/bootloader.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/bootloader.go index aa2b1a6f2..9224080a4 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/bootloader.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/bootloader.go @@ -4,13 +4,8 @@ package bootloader -import ( - "github.com/talos-systems/talos/internal/app/machined/pkg/runtime" -) - // Bootloader describes a bootloader. type Bootloader interface { - Labels() (string, string, error) - Install(string, interface{}, runtime.Sequence) error - Default(string) error + // Install installs the bootloader + Install(bootDisk, arch string) error } diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/boot_label.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/boot_label.go new file mode 100644 index 000000000..9c5b5b67e --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/boot_label.go @@ -0,0 +1,38 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package grub + +import ( + "fmt" + "strings" +) + +// BootLabel represents a boot label, e.g. A or B. +type BootLabel string + +// FlipBootLabel flips the boot entry, e.g. A -> B, B -> A. +func FlipBootLabel(e BootLabel) (BootLabel, error) { + switch e { + case BootA: + return BootB, nil + case BootB: + return BootA, nil + default: + return "", fmt.Errorf("invalid entry: %s", e) + } +} + +// ParseBootLabel parses the given human-readable boot label to a grub.BootLabel. +func ParseBootLabel(name string) (BootLabel, error) { + if strings.HasPrefix(name, string(BootA)) { + return BootA, nil + } + + if strings.HasPrefix(name, string(BootB)) { + return BootB, nil + } + + return "", fmt.Errorf("could not parse boot entry from name: %s", name) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/constants.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/constants.go index 2730a8325..7d1c165b6 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/constants.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/constants.go @@ -8,14 +8,11 @@ import "github.com/talos-systems/talos/pkg/machinery/constants" const ( // BootA is a bootloader label. - BootA = "A" + BootA BootLabel = "A" // BootB is a bootloader label. - BootB = "B" + BootB BootLabel = "B" - // GrubConfig is the path to the grub config. - GrubConfig = constants.BootMountPoint + "/grub/grub.cfg" - - // GrubDeviceMap is the path to the grub device map. - GrubDeviceMap = constants.BootMountPoint + "/grub/device.map" + // ConfigPath is the path to the grub config. + ConfigPath = constants.BootMountPoint + "/grub/grub.cfg" ) diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/decode.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/decode.go new file mode 100644 index 000000000..fd0b70f29 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/decode.go @@ -0,0 +1,150 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package grub + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "regexp" +) + +var ( + defaultEntryRegex = regexp.MustCompile(`(?m)^\s*set default="(.*)"\s*$`) + fallbackEntryRegex = regexp.MustCompile(`(?m)^\s*set fallback="(.*)"\s*$`) + menuEntryRegex = regexp.MustCompile(`(?m)^menuentry "(.+)" {([^}]+)}`) + linuxRegex = regexp.MustCompile(`(?m)^\s*linux\s+(.+?)\s+(.*)$`) + initrdRegex = regexp.MustCompile(`(?m)^\s*initrd\s+(.+)$`) +) + +// Read reads the grub configuration from the disk. +func Read(path string) (*Config, error) { + c, err := ioutil.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + if err != nil { + return nil, err + } + + return Decode(c) +} + +// Decode parses the grub configuration from the given bytes. +func Decode(c []byte) (*Config, error) { + defaultEntryMatches := defaultEntryRegex.FindAllSubmatch(c, -1) + if len(defaultEntryMatches) != 1 { + return nil, fmt.Errorf("failed to find default") + } + + fallbackEntryMatches := fallbackEntryRegex.FindAllSubmatch(c, -1) + if len(fallbackEntryMatches) > 1 { + return nil, fmt.Errorf("found multiple fallback entries") + } + + var fallbackEntry BootLabel + + if len(fallbackEntryMatches) == 1 { + if len(fallbackEntryMatches[0]) != 2 { + return nil, fmt.Errorf("failed to parse fallback entry") + } + + entry, err := ParseBootLabel(string(fallbackEntryMatches[0][1])) + if err != nil { + return nil, err + } + + fallbackEntry = entry + } + + if len(defaultEntryMatches[0]) != 2 { + return nil, fmt.Errorf("expected 2 matches, got %d", len(defaultEntryMatches[0])) + } + + defaultEntry, err := ParseBootLabel(string(defaultEntryMatches[0][1])) + if err != nil { + return nil, err + } + + entries, err := parseEntries(c) + if err != nil { + return nil, err + } + + conf := Config{ + Default: defaultEntry, + Fallback: fallbackEntry, + Entries: entries, + } + + return &conf, nil +} + +func parseEntries(conf []byte) (map[BootLabel]MenuEntry, error) { + entries := make(map[BootLabel]MenuEntry) + + matches := menuEntryRegex.FindAllSubmatch(conf, -1) + for _, m := range matches { + if len(m) != 3 { + return nil, fmt.Errorf("expected 3 matches, got %d", len(m)) + } + + confBlock := m[2] + + linux, cmdline, initrd, err := parseConfBlock(confBlock) + if err != nil { + return nil, err + } + + name := string(m[1]) + + bootEntry, err := ParseBootLabel(name) + if err != nil { + return nil, err + } + + entries[bootEntry] = MenuEntry{ + Name: name, + Linux: linux, + Cmdline: cmdline, + Initrd: initrd, + } + } + + return entries, nil +} + +func parseConfBlock(block []byte) (linux, cmdline, initrd string, err error) { + linuxMatches := linuxRegex.FindAllSubmatch(block, -1) + if len(linuxMatches) != 1 { + return "", "", "", + fmt.Errorf("expected 1 match, got %d", len(linuxMatches)) + } + + if len(linuxMatches[0]) != 3 { + return "", "", "", + fmt.Errorf("expected 3 matches, got %d", len(linuxMatches[0])) + } + + linux = string(linuxMatches[0][1]) + cmdline = string(linuxMatches[0][2]) + + initrdMatches := initrdRegex.FindAllSubmatch(block, -1) + if len(initrdMatches) != 1 { + return "", "", "", + fmt.Errorf("expected 1 match, got %d", len(initrdMatches)) + } + + if len(initrdMatches[0]) != 2 { + return "", "", "", + fmt.Errorf("expected 2 matches, got %d", len(initrdMatches[0])) + } + + initrd = string(initrdMatches[0][1]) + + return linux, cmdline, initrd, nil +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/encode.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/encode.go new file mode 100644 index 000000000..fcaeaa98b --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/encode.go @@ -0,0 +1,66 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package grub + +import ( + "bytes" + "io" + "io/ioutil" + "log" + "os" + "path/filepath" + "text/template" +) + +const confTemplate = `set default="{{ (index .Entries .Default).Name }}" +{{ with (index .Entries .Fallback).Name -}} +set fallback="{{ . }}" +{{- end }} +set timeout=3 + +insmod all_video + +terminal_input console +terminal_output console + +{{ range $key, $entry := .Entries -}} +menuentry "{{ $entry.Name }}" { + set gfxmode=auto + set gfxpayload=text + linux {{ $entry.Linux }} {{ $entry.Cmdline }} + initrd {{ $entry.Initrd }} +} +{{ end -}} +` + +// Write the grub configuration to the given file. +func (c *Config) Write(path string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, os.ModeDir); err != nil { + return err + } + + wr := new(bytes.Buffer) + + err := c.Encode(wr) + if err != nil { + return err + } + + log.Printf("writing %s to disk", path) + + return ioutil.WriteFile(path, wr.Bytes(), 0o600) +} + +// Encode writes the grub configuration to the given writer. +func (c *Config) Encode(wr io.Writer) error { + if err := c.validate(); err != nil { + return err + } + + t := template.Must(template.New("grub").Parse(confTemplate)) + + return t.Execute(wr, c) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/grub.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/grub.go index 649e5ce20..4a9ebc3d3 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/grub.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/grub.go @@ -2,295 +2,72 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +// Package grub provides the interface to the GRUB bootloader: config management, installation, etc. package grub import ( - "bufio" - "bytes" - "errors" "fmt" - "io/ioutil" - "log" - "os" - "os/exec" "path/filepath" - "regexp" - goruntime "runtime" - "strings" - "text/template" - "github.com/talos-systems/go-blockdevice/blockdevice" - - "github.com/talos-systems/talos/internal/app/machined/pkg/runtime" "github.com/talos-systems/talos/pkg/machinery/constants" + "github.com/talos-systems/talos/pkg/version" ) -// Cfg reprsents the cfg file. -type Cfg struct { - Default string - Fallback string - Labels []*Label +// Config represents a grub configuration file (grub.cfg). +type Config struct { + Default BootLabel + Fallback BootLabel + Entries map[BootLabel]MenuEntry } -// Label reprsents a label in the cfg file. -type Label struct { - Root string - Kernel string - Initrd string - Append string -} - -const grubCfgTpl = `set default="{{ .Default }}" -{{ with .Fallback -}} -set fallback="{{ . }}" -{{- end }} -set timeout=3 - -insmod all_video - -terminal_input console -terminal_output console - -{{ range $label := .Labels -}} -menuentry "{{ $label.Root }}" { - set gfxmode=auto - set gfxpayload=text - linux {{ $label.Kernel }} {{ $label.Append }} - initrd {{ $label.Initrd }} -} -{{ end }} -` - -const ( - amd64 = "amd64" - arm64 = "arm64" -) - -// Grub represents the grub bootloader. -type Grub struct { - BootDisk string - Arch string -} - -// Labels implements the Bootloader interface. -func (g *Grub) Labels() (current, next string, err error) { - var b []byte - - if b, err = ioutil.ReadFile(GrubConfig); err != nil { - if errors.Is(err, os.ErrNotExist) { - next = BootA - - return current, next, nil - } - - return "", "", err - } - - re := regexp.MustCompile(`^set default="(.*)"`) - matches := re.FindAllSubmatch(b, -1) - - if len(matches) != 1 { - return "", "", fmt.Errorf("failed to find default") - } - - if len(matches[0]) != 2 { - return "", "", fmt.Errorf("expected 2 matches, got %d", len(matches[0])) - } - - current = string(matches[0][1]) - switch current { - case BootA: - next = BootB - case BootB: - next = BootA - default: - return "", "", fmt.Errorf("unknown grub menuentry: %q", current) - } - - return current, next, err -} - -// BootEntry describes GRUB boot entry. -type BootEntry struct { - // Paths to kernel and initramfs image. - Linux, Initrd string - // Cmdline for the kernel. +// MenuEntry represents a grub menu entry in the grub config file. +type MenuEntry struct { + Name string + Linux string Cmdline string + Initrd string } -// GetCurrentEntry fetches current boot entry, vmlinuz/initrd path, boot args. -// -//nolint:gocyclo -func (g *Grub) GetCurrentEntry() (*BootEntry, error) { - f, err := os.Open(GrubConfig) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - - return nil, err +// NewConfig creates a new grub configuration (nothing is written to disk). +func NewConfig(cmdline string) *Config { + return &Config{ + Default: BootA, + Entries: map[BootLabel]MenuEntry{ + BootA: buildMenuEntry(BootA, cmdline), + }, } - - defer f.Close() //nolint:errcheck - - scanner := bufio.NewScanner(f) - - entry := &BootEntry{} - - var ( - defaultEntry string - currentEntry string - ) - - for scanner.Scan() { - line := scanner.Text() - - switch { - case strings.HasPrefix(line, "set default"): - matches := regexp.MustCompile(`set default="(.*)"`).FindStringSubmatch(line) - if len(matches) != 2 { - return nil, fmt.Errorf("malformed default entry: %q", line) - } - - defaultEntry = matches[1] - case strings.HasPrefix(line, "menuentry"): - matches := regexp.MustCompile(`menuentry "(.*)"`).FindStringSubmatch(line) - if len(matches) != 2 { - return nil, fmt.Errorf("malformed menuentry: %q", line) - } - - currentEntry = matches[1] - case strings.HasPrefix(line, " linux "): - if currentEntry != defaultEntry { - continue - } - - parts := strings.SplitN(line[8:], " ", 2) - - entry.Linux = parts[0] - if len(parts) == 2 { - entry.Cmdline = parts[1] - } - case strings.HasPrefix(line, " initrd "): - if currentEntry != defaultEntry { - continue - } - - entry.Initrd = line[9:] - } - } - - if entry.Linux == "" || entry.Initrd == "" { - return nil, scanner.Err() - } - - return entry, scanner.Err() } -// Install implements the Bootloader interface. It sets up grub with the -// specified kernel parameters. -// -//nolint:gocyclo -func (g *Grub) Install(fallback string, config interface{}, sequence runtime.Sequence) (err error) { - grubcfg, ok := config.(*Cfg) - if !ok { - return errors.New("expected a grub config") +// Put puts a new menu entry to the grub config (nothing is written to disk). +func (c *Config) Put(entry BootLabel, cmdline string) error { + c.Entries[entry] = buildMenuEntry(entry, cmdline) + + return nil +} + +func (c *Config) validate() error { + if _, ok := c.Entries[c.Default]; !ok { + return fmt.Errorf("invalid default entry: %s", c.Default) } - if err = writeCfg(GrubConfig, grubcfg); err != nil { - return err - } - - dev, err := blockdevice.Open(g.BootDisk) - if err != nil { - return err - } - - //nolint:errcheck - defer dev.Close() - - // verify that BootDisk has boot partition - _, err = dev.GetPartition(constants.BootPartitionLabel) - if err != nil { - return err - } - - blk := dev.Device().Name() - - loopDevice := strings.HasPrefix(blk, "/dev/loop") - - var platforms []string - - switch g.Arch { - case amd64: - platforms = []string{"x86_64-efi", "i386-pc"} - case arm64: - platforms = []string{"arm64-efi"} - } - - if goruntime.GOARCH == amd64 && g.Arch == amd64 && !loopDevice { - // let grub choose the platform automatically if not building an image - platforms = []string{""} - } - - for _, platform := range platforms { - args := []string{"--boot-directory=" + constants.BootMountPoint, "--efi-directory=" + constants.EFIMountPoint, "--removable"} - - if loopDevice { - args = append(args, "--no-nvram") + if c.Fallback != "" { + if _, ok := c.Entries[c.Fallback]; !ok { + return fmt.Errorf("invalid fallback entry: %s", c.Fallback) } + } - if platform != "" { - args = append(args, "--target="+platform) - } - - args = append(args, blk) - - log.Printf("executing: grub-install %s", strings.Join(args, " ")) - - cmd := exec.Command("grub-install", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err = cmd.Run(); err != nil { - return fmt.Errorf("failed to install grub: %w", err) - } + if c.Default == c.Fallback { + return fmt.Errorf("default and fallback entries must not be the same") } return nil } -// Default implements the bootloader interface. -func (g *Grub) Default(label string) (err error) { - var b []byte - - if b, err = ioutil.ReadFile(GrubConfig); err != nil { - return err +func buildMenuEntry(entry BootLabel, cmdline string) MenuEntry { + return MenuEntry{ + Name: fmt.Sprintf("%s - %s", entry, version.Short()), + Linux: filepath.Join("/", string(entry), constants.KernelAsset), + Cmdline: cmdline, + Initrd: filepath.Join("/", string(entry), constants.InitramfsAsset), } - - re := regexp.MustCompile(`^set default="(.*)"`) - b = re.ReplaceAll(b, []byte(fmt.Sprintf(`set default="%s"`, label))) - - log.Printf("writing %s to disk", GrubConfig) - - return ioutil.WriteFile(GrubConfig, b, 0o600) -} - -func writeCfg(path string, grubcfg *Cfg) (err error) { - b := []byte{} - wr := bytes.NewBuffer(b) - t := template.Must(template.New("grub").Parse(grubCfgTpl)) - - if err = t.Execute(wr, grubcfg); err != nil { - return err - } - - dir := filepath.Dir(path) - if err = os.MkdirAll(dir, os.ModeDir); err != nil { - return err - } - - log.Printf("writing %s to disk", path) - - return ioutil.WriteFile(path, wr.Bytes(), 0o600) } diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/grub_test.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/grub_test.go new file mode 100644 index 000000000..7a984a30f --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/grub_test.go @@ -0,0 +1,224 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package grub_test + +import ( + "bufio" + "bytes" + _ "embed" + "fmt" + "io" + "io/ioutil" + "os" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/talos-systems/talos/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub" + "github.com/talos-systems/talos/pkg/version" +) + +var ( + //go:embed testdata/grub_parse_test.cfg + grubCfg []byte + + //go:embed testdata/grub_write_test.cfg + newConfig string +) + +func TestDecode(t *testing.T) { + conf, err := grub.Decode(grubCfg) + assert.NoError(t, err) + + assert.Equal(t, grub.BootA, conf.Default) + assert.Equal(t, grub.BootB, conf.Fallback) + + assert.Len(t, conf.Entries, 2) + + a := conf.Entries[grub.BootA] + assert.Equal(t, "A - v1", a.Name) + assert.True(t, strings.HasPrefix(a.Linux, "/A/")) + assert.True(t, strings.HasPrefix(a.Initrd, "/A/")) + assert.Equal(t, "cmdline A", a.Cmdline) + + b := conf.Entries[grub.BootB] + assert.Equal(t, "B - v2", b.Name) + assert.Equal(t, "cmdline B", b.Cmdline) + assert.True(t, strings.HasPrefix(b.Linux, "/B/")) + assert.True(t, strings.HasPrefix(b.Initrd, "/B/")) +} + +func TestParseBootLabel(t *testing.T) { + label, err := grub.ParseBootLabel("A - v1") + assert.NoError(t, err) + assert.Equal(t, grub.BootA, label) + + label, err = grub.ParseBootLabel("B - v2") + assert.NoError(t, err) + assert.Equal(t, grub.BootB, label) + + _, err = grub.ParseBootLabel("C - v3") + assert.Error(t, err) +} + +//nolint:errcheck +func TestWrite(t *testing.T) { + oldName, oldTag := version.Name, version.Tag + + t.Cleanup(func() { + version.Name, version.Tag = oldName, oldTag + }) + + version.Name = "Test" + version.Tag = "v0.0.1" + + tempFile, _ := ioutil.TempFile("", "talos-test-grub-*.cfg") + + defer os.Remove(tempFile.Name()) + + config := grub.NewConfig("cmdline A") + + err := config.Write(tempFile.Name()) + assert.NoError(t, err) + + written, _ := ioutil.ReadFile(tempFile.Name()) + assert.Equal(t, newConfig, string(written)) +} + +func TestPut(t *testing.T) { + config := grub.NewConfig("cmdline A") + err := config.Put(grub.BootB, "cmdline B") + + assert.NoError(t, err) + + assert.Len(t, config.Entries, 2) + assert.Equal(t, "cmdline B", config.Entries[grub.BootB].Cmdline) + + err = config.Put(grub.BootA, "cmdline A 2") + assert.NoError(t, err) + + assert.Equal(t, "cmdline A 2", config.Entries[grub.BootA].Cmdline) +} + +//nolint:errcheck +func TestFallback(t *testing.T) { + config := grub.NewConfig("cmdline A") + _ = config.Put(grub.BootB, "cmdline B") + + config.Fallback = grub.BootB + + var buf bytes.Buffer + _ = config.Encode(&buf) + + result := buf.String() + + assert.Contains(t, result, `set fallback="B - `) + + buf.Reset() + + config.Fallback = "" + _ = config.Encode(&buf) + + result = buf.String() + assert.NotContains(t, result, "set fallback") +} + +type bootEntry struct { + Linux string + Initrd string + Cmdline string +} + +// oldParser is the kexec parser used before the GRUB parser was rewritten. +// +// This makes sure Talos 0.14 can kexec into newly written GRUB config. +// +//nolint:gocyclo +func oldParser(r io.Reader) (*bootEntry, error) { + scanner := bufio.NewScanner(r) + + entry := &bootEntry{} + + var ( + defaultEntry string + currentEntry string + ) + + for scanner.Scan() { + line := scanner.Text() + + switch { + case strings.HasPrefix(line, "set default"): + matches := regexp.MustCompile(`set default="(.*)"`).FindStringSubmatch(line) + if len(matches) != 2 { + return nil, fmt.Errorf("malformed default entry: %q", line) + } + + defaultEntry = matches[1] + case strings.HasPrefix(line, "menuentry"): + matches := regexp.MustCompile(`menuentry "(.*)"`).FindStringSubmatch(line) + if len(matches) != 2 { + return nil, fmt.Errorf("malformed menuentry: %q", line) + } + + currentEntry = matches[1] + case strings.HasPrefix(line, " linux "): + if currentEntry != defaultEntry { + continue + } + + parts := strings.SplitN(line[8:], " ", 2) + + entry.Linux = parts[0] + if len(parts) == 2 { + entry.Cmdline = parts[1] + } + case strings.HasPrefix(line, " initrd "): + if currentEntry != defaultEntry { + continue + } + + entry.Initrd = line[9:] + } + } + + if entry.Linux == "" || entry.Initrd == "" { + return nil, scanner.Err() + } + + return entry, scanner.Err() +} + +func TestBackwardsCompat(t *testing.T) { + oldName, oldTag := version.Name, version.Tag + + t.Cleanup(func() { + version.Name, version.Tag = oldName, oldTag + }) + + version.Name = "Test" + version.Tag = "v0.0.1" + + var buf bytes.Buffer + + config := grub.NewConfig("cmdline A") + require.NoError(t, config.Put(grub.BootB, "cmdline B")) + config.Default = grub.BootB + + err := config.Encode(&buf) + assert.NoError(t, err) + + entry, err := oldParser(&buf) + require.NoError(t, err) + + assert.Equal(t, &bootEntry{ + Linux: "/B/vmlinuz", + Initrd: "/B/initramfs.xz", + Cmdline: "cmdline B", + }, entry) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/install.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/install.go new file mode 100644 index 000000000..2c7e52684 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/install.go @@ -0,0 +1,99 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package grub + +import ( + "fmt" + "log" + "os" + "os/exec" + "runtime" + "strings" + + "github.com/talos-systems/go-blockdevice/blockdevice" + + "github.com/talos-systems/talos/pkg/machinery/constants" +) + +const ( + amd64 = "amd64" + arm64 = "arm64" +) + +// Install validates the grub configuration and writes it to the disk. +//nolint:gocyclo +func (c *Config) Install(bootDisk, arch string) error { + if err := c.Write(ConfigPath); err != nil { + return err + } + + blk, err := getBlockDeviceName(bootDisk) + if err != nil { + return err + } + + loopDevice := strings.HasPrefix(blk, "/dev/loop") + + var platforms []string + + switch arch { + case amd64: + platforms = []string{"x86_64-efi", "i386-pc"} + case arm64: + platforms = []string{"arm64-efi"} + } + + if runtime.GOARCH == amd64 && arch == amd64 && !loopDevice { + // let grub choose the platform automatically if not building an image + platforms = []string{""} + } + + for _, platform := range platforms { + args := []string{"--boot-directory=" + constants.BootMountPoint, "--efi-directory=" + + constants.EFIMountPoint, "--removable"} + + if loopDevice { + args = append(args, "--no-nvram") + } + + if platform != "" { + args = append(args, "--target="+platform) + } + + args = append(args, blk) + + log.Printf("executing: grub-install %s", strings.Join(args, " ")) + + cmd := exec.Command("grub-install", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err = cmd.Run(); err != nil { + return fmt.Errorf("failed to install grub: %w", err) + } + } + + return nil +} + +func getBlockDeviceName(bootDisk string) (string, error) { + dev, err := blockdevice.Open(bootDisk) + if err != nil { + return "", err + } + + //nolint:errcheck + defer dev.Close() + + // verify that BootDisk has boot partition + _, err = dev.GetPartition(constants.BootPartitionLabel) + if err != nil { + return "", err + } + + blk := dev.Device().Name() + + return blk, nil +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/testdata/grub_parse_test.cfg b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/testdata/grub_parse_test.cfg new file mode 100644 index 000000000..8a40810a9 --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/testdata/grub_parse_test.cfg @@ -0,0 +1,22 @@ +set default="A - v1" +set timeout=3 +set fallback="B - v2" + +insmod all_video + +terminal_input console +terminal_output console + +menuentry "A - v1" { + set gfxmode=auto + set gfxpayload=text + linux /A/vmlinuz cmdline A + initrd /A/initramfs.xz +} + +menuentry "B - v2" { + set gfxmode=auto + set gfxpayload=text + linux /B/vmlinuz cmdline B + initrd /B/initramfs.xz +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/testdata/grub_write_test.cfg b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/testdata/grub_write_test.cfg new file mode 100644 index 000000000..dff289ffd --- /dev/null +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/grub/testdata/grub_write_test.cfg @@ -0,0 +1,15 @@ +set default="A - Test v0.0.1" + +set timeout=3 + +insmod all_video + +terminal_input console +terminal_output console + +menuentry "A - Test v0.0.1" { + set gfxmode=auto + set gfxpayload=text + linux /A/vmlinuz cmdline A + initrd /A/initramfs.xz +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/meta.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/meta.go index 709050af0..f1c2d0900 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/meta.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/meta.go @@ -112,9 +112,18 @@ func (m *Meta) Revert() (err error) { return m.Write() } - g := &grub.Grub{} + conf, err := grub.Read(grub.ConfigPath) + if err != nil { + return err + } - if err = g.Default(label); err != nil { + bootEntry, err := grub.ParseBootLabel(label) + if err != nil { + return err + } + + conf.Default = bootEntry + if err = conf.Write(grub.ConfigPath); err != nil { return err } diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/syslinux/constants.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/syslinux/constants.go deleted file mode 100644 index 86f3fb4d6..000000000 --- a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/syslinux/constants.go +++ /dev/null @@ -1,25 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package syslinux - -import "github.com/talos-systems/talos/pkg/machinery/constants" - -const ( - // BootA is a syslinux label. - BootA = "boot-a" - - // BootB is a syslinux label. - BootB = "boot-b" - - // SyslinuxLdlinux is the path to ldlinux.sys. - SyslinuxLdlinux = constants.BootMountPoint + "/syslinux/ldlinux.sys" - - // SyslinuxConfig is the path to the Syslinux config. - SyslinuxConfig = constants.BootMountPoint + "/syslinux/syslinux.cfg" - - gptmbrbin = "/usr/lib/syslinux/gptmbr.bin" - syslinuxefi = "/usr/lib/syslinux/syslinux.efi" - ldlinuxe64 = "/usr/lib/syslinux/ldlinux.e64" -) diff --git a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/syslinux/syslinux.go b/internal/app/machined/pkg/runtime/v1alpha1/bootloader/syslinux/syslinux.go deleted file mode 100644 index 6861a50a0..000000000 --- a/internal/app/machined/pkg/runtime/v1alpha1/bootloader/syslinux/syslinux.go +++ /dev/null @@ -1,314 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package syslinux - -import ( - "bytes" - "errors" - "fmt" - "io" - "io/ioutil" - "log" - "os" - "path/filepath" - "regexp" - "text/template" - - "github.com/talos-systems/go-cmd/pkg/cmd" - "golang.org/x/sys/unix" - - "github.com/talos-systems/talos/internal/app/machined/pkg/runtime" - advcommon "github.com/talos-systems/talos/internal/app/machined/pkg/runtime/v1alpha1/bootloader/adv" - "github.com/talos-systems/talos/internal/app/machined/pkg/runtime/v1alpha1/bootloader/adv/syslinux" - "github.com/talos-systems/talos/pkg/machinery/constants" -) - -const syslinuxCfgTpl = `DEFAULT {{ .Default }} -PROMPT 1 -TIMEOUT 50 - -{{- range .Labels }} -INCLUDE /{{ .Root }}/include.cfg -{{- end }}` - -const syslinuxLabelTpl = `LABEL {{ .Root }} - KERNEL {{ .Kernel }} - INITRD {{ .Initrd }} - APPEND {{ .Append }} -` - -// Cfg reprsents the cfg file. -type Cfg struct { - Default string - Labels []*Label -} - -// Label reprsents a label in the cfg file. -type Label struct { - Root string - Kernel string - Initrd string - Append string -} - -// Syslinux represents the syslinux bootloader. -type Syslinux struct{} - -// Prepare implements the Bootloader interface. It works by writing -// gptmbr.bin to a block device. -func Prepare(dev string) (err error) { - b, err := ioutil.ReadFile(gptmbrbin) - if err != nil { - return err - } - - f, err := os.OpenFile(dev, os.O_WRONLY|unix.O_CLOEXEC, os.ModeDevice) - if err != nil { - return err - } - - //nolint:errcheck - defer f.Close() - - if _, err := f.Write(b); err != nil { - return err - } - - return nil -} - -// Install implements the Bootloader interface. It sets up syslinux with the -// specified kernel parameters. -func Install(fallback string, config interface{}, sequence runtime.Sequence, bootPartitionFound bool) (err error) { - syslinuxcfg, ok := config.(*Cfg) - if !ok { - return errors.New("expected a syslinux config") - } - - if err = writeCfg(constants.BootMountPoint, SyslinuxConfig, syslinuxcfg); err != nil { - return err - } - - if sequence == runtime.SequenceUpgrade && bootPartitionFound { - log.Println("updating syslinux") - - if err = update(); err != nil { - return err - } - } else { - log.Println("installing syslinux") - - if err = install(); err != nil { - return err - } - } - - if err = writeUEFIFiles(); err != nil { - return err - } - - if sequence == runtime.SequenceUpgrade { - if err = setADV(SyslinuxLdlinux, fallback); err != nil { - return err - } - } - - return nil -} - -// Labels parses the syslinux config and returns the current active label, and -// what should be the next label. -func Labels() (current, next string, err error) { - var b []byte - - if b, err = ioutil.ReadFile(SyslinuxConfig); err != nil { - if errors.Is(err, os.ErrNotExist) { - current = BootA - - return current, "", nil - } - - return "", "", err - } - - re := regexp.MustCompile(`^DEFAULT\s(.*)`) - matches := re.FindSubmatch(b) - - if len(matches) != 2 { - return "", "", fmt.Errorf("expected 2 matches, got %d", len(matches)) - } - - current = string(matches[1]) - switch current { - case BootA: - next = BootB - case BootB: - next = BootA - default: - return "", "", fmt.Errorf("unknown syslinux label: %q", current) - } - - return current, next, err -} - -// Default sets the default syslinx label. -func Default(label string) (err error) { - log.Printf("setting default label to %q", label) - - var b []byte - - if b, err = ioutil.ReadFile(SyslinuxConfig); err != nil { - return err - } - - re := regexp.MustCompile(`^DEFAULT\s(.*)`) - matches := re.FindSubmatch(b) - - if len(matches) != 2 { - return fmt.Errorf("expected 2 matches, got %d", len(matches)) - } - - b = re.ReplaceAll(b, []byte(fmt.Sprintf("DEFAULT %s", label))) - - return ioutil.WriteFile(SyslinuxConfig, b, 0o600) -} - -func writeCfg(base, path string, syslinuxcfg *Cfg) (err error) { - b := []byte{} - wr := bytes.NewBuffer(b) - t := template.Must(template.New("syslinux").Parse(syslinuxCfgTpl)) - - if err = t.Execute(wr, syslinuxcfg); err != nil { - return err - } - - dir := filepath.Dir(path) - if err = os.MkdirAll(dir, os.ModeDir); err != nil { - return err - } - - log.Printf("writing %s to disk", path) - - if err = ioutil.WriteFile(path, wr.Bytes(), 0o600); err != nil { - return err - } - - for _, label := range syslinuxcfg.Labels { - b = []byte{} - wr = bytes.NewBuffer(b) - t = template.Must(template.New("syslinux").Parse(syslinuxLabelTpl)) - - if err = t.Execute(wr, label); err != nil { - return err - } - - dir = filepath.Join(base, label.Root) - - if err = os.MkdirAll(dir, os.ModeDir); err != nil { - return err - } - - log.Printf("writing syslinux label %q to disk", label.Root) - - if err = ioutil.WriteFile(filepath.Join(dir, "include.cfg"), wr.Bytes(), 0o600); err != nil { - return err - } - } - - return nil -} - -func copyFile(src, dst string) error { - info, err := os.Stat(src) - if err != nil { - return err - } - - if !info.Mode().IsRegular() { - return fmt.Errorf("%q is not a regular file", src) - } - - s, err := os.Open(src) - if err != nil { - return err - } - - //nolint:errcheck - defer s.Close() - - d, err := os.Create(dst) - if err != nil { - return err - } - - //nolint:errcheck - defer d.Close() - - _, err = io.Copy(d, s) - - return err -} - -func writeUEFIFiles() (err error) { - dir := filepath.Join(constants.BootMountPoint, "EFI", "BOOT") - - if err = os.MkdirAll(dir, 0o700); err != nil { - return err - } - - for src, dest := range map[string]string{syslinuxefi: filepath.Join(dir, "BOOTX64.EFI"), ldlinuxe64: filepath.Join(dir, "ldlinux.e64")} { - if err = copyFile(src, dest); err != nil { - return err - } - } - - return nil -} - -func install() (err error) { - _, err = cmd.Run("extlinux", "--install", filepath.Dir(SyslinuxConfig)) - if err != nil { - return fmt.Errorf("failed to install syslinux: %w", err) - } - - return nil -} - -func update() (err error) { - if _, err = cmd.Run("extlinux", "--update", filepath.Dir(SyslinuxConfig)); err != nil { - return fmt.Errorf("failed to update syslinux: %w", err) - } - - return nil -} - -func setADV(ldlinux, fallback string) (err error) { - var f *os.File - - if f, err = os.OpenFile(ldlinux, os.O_RDWR, 0o700); err != nil { - return err - } - - //nolint:errcheck - defer f.Close() - - var adv syslinux.ADV - - if adv, err = syslinux.NewADV(f); err != nil { - return err - } - - if ok := adv.SetTag(advcommon.Upgrade, fallback); !ok { - return fmt.Errorf("failed to set upgrade tag: %q", fallback) - } - - if _, err = f.Write(adv); err != nil { - return err - } - - log.Printf("set fallback to %q", fallback) - - return nil -} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer.go b/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer.go index 09bf19b1b..6ecbb7ee7 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer.go @@ -200,10 +200,6 @@ func (*Sequencer) Boot(r runtime.Runtime) []runtime.Phase { r.State().Platform().Mode() != runtime.ModeContainer, "ephemeral", MountEphemeralPartition, - ).AppendWhen( - r.State().Platform().Mode() != runtime.ModeContainer, - "verifyInstall", - VerifyInstallation, ).Append( "var", SetupVarDirectory, diff --git a/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer_tasks.go b/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer_tasks.go index a32c7ff65..5d740bfdd 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer_tasks.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/v1alpha1_sequencer_tasks.go @@ -682,37 +682,6 @@ func StopAllServices(seq runtime.Sequence, data interface{}) (runtime.TaskExecut }, "stopAllServices" } -// VerifyInstallation represents the VerifyInstallation task. -func VerifyInstallation(seq runtime.Sequence, data interface{}) (runtime.TaskExecutionFunc, string) { - return func(ctx context.Context, logger *log.Logger, r runtime.Runtime) (err error) { - var ( - current string - next string - disk string - ) - - disk, err = r.Config().Machine().Install().Disk() - if err != nil { - return err - } - - grub := &grub.Grub{ - BootDisk: disk, - } - - current, next, err = grub.Labels() - if err != nil { - return err - } - - if current == "" && next == "" { - return fmt.Errorf("bootloader is not configured") - } - - return err - }, "verifyInstallation" -} - // MountOverlayFilesystems represents the MountOverlayFilesystems task. func MountOverlayFilesystems(seq runtime.Sequence, data interface{}) (runtime.TaskExecutionFunc, string) { return func(ctx context.Context, logger *log.Logger, r runtime.Runtime) (err error) { @@ -1740,26 +1709,22 @@ func KexecPrepare(seq runtime.Sequence, data interface{}) (runtime.TaskExecution return nil } - disk, err := r.Config().Machine().Install().Disk() + conf, err := grub.Read(grub.ConfigPath) if err != nil { return err } - grub := &grub.Grub{ - BootDisk: disk, - } - - entry, err := grub.GetCurrentEntry() - if err != nil { - return err - } - - if entry == nil { + if conf == nil { return nil } - kernelPath := filepath.Join(constants.BootMountPoint, entry.Linux) - initrdPath := filepath.Join(constants.BootMountPoint, entry.Initrd) + defaultEntry, ok := conf.Entries[conf.Default] + if !ok { + return nil + } + + kernelPath := filepath.Join(constants.BootMountPoint, defaultEntry.Linux) + initrdPath := filepath.Join(constants.BootMountPoint, defaultEntry.Initrd) kernel, err := os.Open(kernelPath) if err != nil { @@ -1775,7 +1740,7 @@ func KexecPrepare(seq runtime.Sequence, data interface{}) (runtime.TaskExecution defer initrd.Close() //nolint:errcheck - cmdline := strings.TrimSpace(entry.Cmdline) + cmdline := strings.TrimSpace(defaultEntry.Cmdline) if err = unix.KexecFileLoad(int(kernel.Fd()), int(initrd.Fd()), cmdline, 0); err != nil { switch { diff --git a/pkg/version/version.go b/pkg/version/version.go index aa81e6330..9509f5615 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -77,5 +77,10 @@ func printLong(w io.Writer, v *machineapi.VersionInfo) { // PrintShortVersion prints the tag and SHA. func PrintShortVersion() { - fmt.Printf("%s %s-%s\n", Name, Tag, SHA) + fmt.Println(Short()) +} + +// Short returns the short version string consist of name, tag and SHA. +func Short() string { + return fmt.Sprintf("%s %s", Name, Tag) }