Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion cmd/nullbootctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,16 @@ func main() {
}
}

km, err := efibootmgr.NewKernelManager(esp, kernelSourceDir, vendor, maybeBm)
config, err := efibootmgr.ReadConfig(efibootmgr.DefaultConfigPath)
if err != nil {
log.Print(err)
os.Exit(1)
}
if len(config.KernelPriority) > 0 {
log.Println("using kernel priorities:", config.KernelPriority)
}

km, err := efibootmgr.NewKernelManager(esp, kernelSourceDir, vendor, maybeBm, config)
if err != nil {
log.Print(err)
os.Exit(1)
Expand Down
108 changes: 108 additions & 0 deletions efibootmgr/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// This file is part of nullboot
// Copyright 2026 Canonical Ltd.
// SPDX-License-Identifier: GPL-3.0-only

package efibootmgr

import (
"fmt"
"io"
"os"
"path"
"regexp"
"strings"

"gopkg.in/yaml.v2"
)

// DefaultConfigPath is the default nullboot configuration file.
const DefaultConfigPath = "/etc/nullboot.conf"

// Configuration describes kernel boot priorities, like
// GRUB_FLAVOUR_ORDER (see debian/grub-sort-version). Each flavour is
// compiled to [\s\S]*-<flavour>(\s*\d*)$ and matched against the
// kernel ABI. Higher priority flavours sort first; unlisted flavours
// have priority 0.
type Configuration struct {
priorities map[int][]*regexp.Regexp

KernelPriority map[string]int `yaml:"kernel-priority"`
}

// ReadConfig reads YAML configuration from configPath and any
// drop-ins in configPath.d, e.g.:
//
// kernel-priority:
// fips: 1000
// azure-fde: 100
//
// All files are optional; drop-ins merge kernel-priority keys,
// overriding earlier files.
func ReadConfig(configPath string) (*Configuration, error) {
config := &Configuration{KernelPriority: map[string]int{}}

paths := []string{configPath}
entries, err := appFs.ReadDir(configPath + ".d")
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("cannot read configuration drop-in directory: %w", err)
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".conf") {
paths = append(paths, path.Join(configPath+".d", e.Name()))
}
}

for _, p := range paths {
if err := config.readFile(p); err != nil {
return nil, err
}
}

config.priorities = map[int][]*regexp.Regexp{}
for flavour, priority := range config.KernelPriority {
re, err := regexp.Compile(`[\s\S]*-` + regexp.QuoteMeta(flavour) + `(\s*\d*)$`)
if err != nil {
return nil, fmt.Errorf("invalid flavour %q: %w", flavour, err)
}
config.priorities[priority] = append(config.priorities[priority], re)
}
return config, nil
}

// readFile merges a single YAML configuration file into c. Missing
// files are ignored; yaml.v2 merges map keys into existing maps.
func (c *Configuration) readFile(configPath string) error {
file, err := appFs.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("cannot open configuration file %s: %w", configPath, err)
}
defer func() { _ = file.Close() }()

data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("cannot read configuration file %s: %w", configPath, err)
}
if err := yaml.Unmarshal(data, c); err != nil {
return fmt.Errorf("cannot parse configuration file %s: %w", configPath, err)
}
return nil
}

// weight returns the priority of the flavour matching abi, or 0 if
// none matches.
func (c *Configuration) weight(abi string) int {
if c == nil {
return 0
}
for priority, res := range c.priorities {
for _, re := range res {
if re.MatchString(abi) {
return priority
}
}
}
return 0
}
114 changes: 114 additions & 0 deletions efibootmgr/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// This file is part of nullboot
// Copyright 2026 Canonical Ltd.
// SPDX-License-Identifier: GPL-3.0-only

package efibootmgr

import (
"reflect"
"testing"

"github.com/spf13/afero"
)

func TestReadConfig(t *testing.T) {
memFs := afero.NewMemMapFs()
appFs = MapFS{memFs}

config, err := ReadConfig("/etc/nullboot.conf")
if err != nil {
t.Fatalf("unexpected error reading missing configuration: %v", err)
}
if len(config.KernelPriority) != 0 {
t.Errorf("expected no priorities for missing file, got %v", config.KernelPriority)
}
if got := config.weight("6.8.0-52-fips"); got != 0 {
t.Errorf("expected weight 0 without configuration, got %d", got)
}

if err := afero.WriteFile(memFs, "/etc/nullboot.conf", []byte(`# Prefer FIPS kernels over Azure FDE kernels
kernel-priority:
fips: 1000
azure-fde: 100
`), 0644); err != nil {
t.Fatal(err)
}

config, err = ReadConfig("/etc/nullboot.conf")
if err != nil {
t.Fatalf("unexpected error reading configuration: %v", err)
}
want := map[string]int{"fips": 1000, "azure-fde": 100}
if !reflect.DeepEqual(config.KernelPriority, want) {
t.Errorf("unexpected kernel priorities: %v", config.KernelPriority)
}
}

func TestConfigWeight(t *testing.T) {
memFs := afero.NewMemMapFs()
appFs = MapFS{memFs}
if err := afero.WriteFile(memFs, "/etc/nullboot.conf", []byte(`kernel-priority:
fips: 1000
azure-fde: 100
`), 0644); err != nil {
t.Fatal(err)
}

config, err := ReadConfig("/etc/nullboot.conf")
if err != nil {
t.Fatalf("unexpected error reading configuration: %v", err)
}

tests := []struct {
abi string
want int
}{
{"6.8.0-52-fips", 1000},
{"6.8.0-52-azure-fde", 100}, // multi-dash flavour
{"6.8.0-1003-azure-fde", 100},
{"6.8.0-52-generic", 0}, // unlisted flavour has no priority
{"6.8.0-52-lowlatency", 0},
}
for _, tt := range tests {
if got := config.weight(tt.abi); got != tt.want {
t.Errorf("weight(%q): expected %d, got %d", tt.abi, tt.want, got)
}
}
}

func TestReadConfigDropIns(t *testing.T) {
memFs := afero.NewMemMapFs()
appFs = MapFS{memFs}

for name, content := range map[string]string{
"/etc/nullboot.conf": "kernel-priority: {fips: 1000, azure-fde: 100}\n",
"/etc/nullboot.conf.d/10-override.conf": "kernel-priority: {fips: 500}\n",
"/etc/nullboot.conf.d/20-lowlatency.conf": "kernel-priority: {lowlatency: 10}\n",
"/etc/nullboot.conf.d/ignored.txt": "kernel-priority: {generic: 1}\n",
} {
if err := afero.WriteFile(memFs, name, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}

config, err := ReadConfig("/etc/nullboot.conf")
if err != nil {
t.Fatalf("unexpected error reading configuration: %v", err)
}
want := map[string]int{"fips": 500, "azure-fde": 100, "lowlatency": 10}
if !reflect.DeepEqual(config.KernelPriority, want) {
t.Errorf("expected kernel priorities %v, got %v", want, config.KernelPriority)
}
}

func TestReadConfigInvalidYAML(t *testing.T) {
memFs := afero.NewMemMapFs()
appFs = MapFS{memFs}
if err := afero.WriteFile(memFs, "/etc/nullboot.conf", []byte("kernel-priority: [not a map"), 0644); err != nil {
t.Fatal(err)
}

if _, err := ReadConfig("/etc/nullboot.conf"); err == nil {
t.Error("expected error for malformed YAML, got nil")
}
}
43 changes: 35 additions & 8 deletions efibootmgr/kernel.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,30 @@ import (

const kernelPrefix = "kernel.efi-"

// WeightedVersion orders versions by priority first, then by
// version. A zero priority sorts like a plain version.Version.
type WeightedVersion struct {
version.Version
weight int
}

// GreaterThan returns true if v sorts before v2: either a higher
// priority, or the same priority with a greater version.
func (v WeightedVersion) GreaterThan(v2 WeightedVersion) bool {
if v.weight != v2.weight {
return v.weight > v2.weight
}
return v.Version.GreaterThan(v2.Version)
}

// Equal returns true if the versions are equal; the weight derives
// from the version string, so equal versions have equal weights.
func (v WeightedVersion) Equal(v2 WeightedVersion) bool {
return v.Version.Equal(v2.Version)
}

type Kernel struct {
Version version.Version
Version WeightedVersion
FilePath string
}

Expand All @@ -30,14 +52,16 @@ func (k *Kernel) Equals(other Kernel) bool {
return k.Version.Equal(other.Version) && k.GetKernelName() == other.GetKernelName()
}

func NewKernel(kernelPath string) (Kernel, error) {
// NewKernel creates a Kernel from its path. The kernel's weight is
// computed from config, which may be nil.
func NewKernel(kernelPath string, config *Configuration) (Kernel, error) {
kernelName := path.Base(kernelPath)
if versionStr, err := getKernelABI(kernelName); err == nil {
v, err := version.NewVersion(versionStr)
if err != nil {
return Kernel{}, fmt.Errorf("could not parse kernel version of %s: %w", kernelName, err)
}
return Kernel{v, kernelPath}, nil
return Kernel{WeightedVersion{Version: v, weight: config.weight(versionStr)}, kernelPath}, nil
}
return Kernel{}, fmt.Errorf("unrecognized kernel naming format: %s", kernelName)
}
Expand All @@ -58,17 +82,20 @@ type KernelManager struct {
targetKernels []Kernel // kernels in targetDir
kernelEntries []KernelEntry // boot entries filled by InstallKernels
kernelOptions string // options to pass to kernel
bootManager *BootManager // The EFI boot manager
bootManager *BootManager // The EFI boot manager
config *Configuration // nullboot configuration
}

// NewKernelManager returns a new kernel manager managing kernels in the host system
func NewKernelManager(esp, sourceDir, vendor string, bootManager *BootManager) (*KernelManager, error) {
// NewKernelManager returns a new kernel manager managing kernels in the
// host system. config may be nil.
func NewKernelManager(esp, sourceDir, vendor string, bootManager *BootManager, config *Configuration) (*KernelManager, error) {
var km KernelManager
var err error

km.sourceDir = sourceDir
km.targetDir = path.Join(esp, "EFI", vendor)
km.bootManager = bootManager
km.config = config

if file, err := appFs.Open("/etc/kernel/cmdline"); err == nil {
defer file.Close()
Expand Down Expand Up @@ -103,13 +130,13 @@ func (km *KernelManager) readKernels(dir string) ([]Kernel, error) {
if !hasKernelPrefix(e.Name()) {
continue
}
kernel, err := NewKernel(path.Join(dir, e.Name()))
kernel, err := NewKernel(path.Join(dir, e.Name()), km.config)
if err != nil {
return []Kernel{}, err
}
kernels = append(kernels, kernel)
}
// Sort descending
// Sort descending by flavour preference group, then by version
sort.Slice(kernels, func(i, j int) bool {
a := kernels[i].Version
b := kernels[j].Version
Expand Down
Loading
Loading