Skip to content
Merged
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
9 changes: 9 additions & 0 deletions lib/hypervisor/qemu/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import (
"github.com/kernel/hypeman/lib/hypervisor"
)

// microVMFirmware is the firmware file microvm guests boot with. It is
// resolved against QEMU's firmware search path at launch.
const microVMFirmware = "qboot.rom"

// BuildArgs converts hypervisor.VMConfig to command-line arguments for standard
// QEMU. Backend starters use buildArgs with their private machine profile.
func BuildArgs(cfg hypervisor.VMConfig) []string {
Expand All @@ -29,6 +33,11 @@ func buildArgs(cfg hypervisor.VMConfig, machine MachineType) []string {
// Do not allow a host qemu.conf to add devices outside microvm's
// documented eight virtio-mmio-device limit.
args = append(args, "-no-user-config")
// Pin the firmware instead of accepting whatever the distro's
// bios-microvm.bin resolves to: with SeaBIOS there, guests
// intermittently crash before the serial console comes up, while
// qboot boots them reliably.
args = append(args, "-bios", microVMFirmware)
}

// CPU configuration
Expand Down
5 changes: 5 additions & 0 deletions lib/hypervisor/qemu/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package qemu

import (
"slices"
"testing"

"github.com/kernel/hypeman/lib/hypervisor"
Expand Down Expand Up @@ -249,6 +250,10 @@ func TestBuildArgs_MicroVM(t *testing.T) {
args := buildArgs(cfg, MachineTypeMicroVM)
assert.Contains(t, args, "microvm,accel=kvm")
assert.Contains(t, args, "-no-user-config")
bios := slices.Index(args, "-bios")
if assert.GreaterOrEqual(t, bios, 0) && assert.Less(t, bios+1, len(args)) {
assert.Equal(t, microVMFirmware, args[bios+1], "-bios must be followed by the pinned firmware")
}
assert.Contains(t, args, "virtio-blk-device,drive=drive0")
assert.Contains(t, args, "virtio-net-device,netdev=net0,mac=02:00:00:ab:cd:ef")
assert.Contains(t, args, "vhost-vsock-device,guest-cid=123")
Expand Down
61 changes: 60 additions & 1 deletion lib/hypervisor/qemu/register_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/kernel/hypeman/lib/hypervisor"
)
Expand Down Expand Up @@ -35,7 +39,7 @@ func init() {
if _, err := microVMMachineType(); err == nil {
hypervisor.RegisterRuntime(hypervisor.TypeQEMUMicroVM, hypervisor.RuntimeRegistration{
Capabilities: MicroVMProfile{}.capabilities,
LaunchCheck: launchPrereqCache.Check,
LaunchCheck: microVMPrereqCache.Check,
})
}
}
Expand All @@ -47,6 +51,61 @@ func init() {
// prerequisites requires restarting Hypeman.
var launchPrereqCache = newLaunchCheckCache(checkLaunchPrerequisites)

// microVMPrereqCache adds the microvm-only firmware prerequisite on top of
// the shared QEMU prerequisites: microvm guests boot with a pinned firmware
// file (microVMFirmware), so a host missing it must not report qemu-microvm
// available.
var microVMPrereqCache = newLaunchCheckCache(checkMicroVMLaunchPrerequisites)

func checkMicroVMLaunchPrerequisites() error {
if err := checkLaunchPrerequisites(); err != nil {
return err
}
binaryPath, err := (&Starter{}).GetBinaryPath(nil, "")
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), versionProbeTimeout)
defer cancel()
dirs, err := firmwareSearchDirs(ctx, binaryPath)
if err != nil {
return fmt.Errorf("qemu binary %s: %w", binaryPath, err)
}
return requireMicroVMFirmware(dirs)
}

// firmwareSearchDirs returns the directories QEMU searches for firmware
// files such as the one named by -bios.
func firmwareSearchDirs(ctx context.Context, binaryPath string) ([]string, error) {
cmd := exec.CommandContext(ctx, binaryPath, "-L", "help")
cmd.WaitDelay = time.Second
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("list firmware search path: %w", err)
}
dirs := make([]string, 0, 4)
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if line = strings.TrimSpace(line); line != "" {
dirs = append(dirs, line)
}
}
if len(dirs) == 0 {
return nil, fmt.Errorf("list firmware search path: empty output")
}
return dirs, nil
}
Comment thread
sjmiller609 marked this conversation as resolved.

// requireMicroVMFirmware verifies the pinned microvm firmware file exists on
// QEMU's firmware search path, as launches resolve it by name.
func requireMicroVMFirmware(dirs []string) error {
for _, dir := range dirs {
if info, err := os.Stat(filepath.Join(dir, microVMFirmware)); err == nil && !info.IsDir() {
return nil
}
}
return fmt.Errorf("microvm firmware %s not found on the QEMU firmware search path %v; install the QEMU data files (qemu-system-data) for the host QEMU", microVMFirmware, dirs)
}

// checkLaunchPrerequisites is the capability registry's side-effect-free
// launch check for both QEMU boards (via launchPrereqCache): it resolves the
// system binary with the same lookup launches use, then verifies the
Expand Down
41 changes: 39 additions & 2 deletions lib/hypervisor/qemu/register_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,17 @@ func TestMicroVMRegistrationMatchesBoardSupport(t *testing.T) {
func TestQEMUAvailabilityTracksLaunchPrerequisites(t *testing.T) {
t.Parallel()

prereqErr := checkLaunchPrerequisites()
standardErr := checkLaunchPrerequisites()
microVMErr := checkMicroVMLaunchPrerequisites()
checked := 0
for _, rt := range hypervisor.RegisteredRuntimes() {
if rt.Type != hypervisor.TypeQEMU && rt.Type != hypervisor.TypeQEMUMicroVM {
var prereqErr error
switch rt.Type {
case hypervisor.TypeQEMU:
prereqErr = standardErr
case hypervisor.TypeQEMUMicroVM:
prereqErr = microVMErr
default:
continue
}
checked++
Expand All @@ -55,6 +62,36 @@ func TestQEMUAvailabilityTracksLaunchPrerequisites(t *testing.T) {
require.GreaterOrEqual(t, checked, 1, "standard qemu must be registered on Linux")
}

// TestRequireMicroVMFirmware covers the microvm-only launch prerequisite:
// the pinned firmware file must be present on QEMU's firmware search path,
// or every microvm launch fails loading the BIOS.
func TestRequireMicroVMFirmware(t *testing.T) {
t.Parallel()

dir := t.TempDir()
require.ErrorContains(t, requireMicroVMFirmware([]string{dir}), microVMFirmware)
require.NoError(t, os.WriteFile(filepath.Join(dir, microVMFirmware), []byte("x"), 0o644))
require.NoError(t, requireMicroVMFirmware([]string{t.TempDir(), dir}))
}

// TestFirmwareSearchDirs parses the firmware search path printed by
// `qemu -L help`, one directory per line.
func TestFirmwareSearchDirs(t *testing.T) {
t.Parallel()

dir := t.TempDir()
binary := filepath.Join(dir, "qemu-system-fake")
script := "#!/bin/sh\nprintf '/usr/share/qemu\\n/usr/share/seabios\\n'\n"
require.NoError(t, os.WriteFile(binary, []byte(script), 0o755))
var dirs []string
require.NoError(t, retryingETXTBSY(t, func() error {
var err error
dirs, err = firmwareSearchDirs(context.Background(), binary)
return err
}))
require.Equal(t, []string{"/usr/share/qemu", "/usr/share/seabios"}, dirs)
}

// fakeQEMUBinary writes an executable script that mimics `qemu --version`
// output, standing in for a working system QEMU.
func fakeQEMUBinary(t *testing.T, dir string) string {
Expand Down
23 changes: 20 additions & 3 deletions lib/instances/test_network_config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package instances

import (
"context"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -41,13 +42,29 @@ const testIPTablesWaitSeconds = "5"
// transient lock contention before giving up.
const iptablesDeleteAttempts = 3

// testNetworkCommandTimeout bounds a single ip/iptables cleanup invocation.
// The -w flag only caps waiting for the xtables lock; a process stuck past
// that would otherwise hang the cleanup (and the test-binary timeout) —
// callers log the error and continue.
const testNetworkCommandTimeout = 30 * time.Second

func testNetworkCommand(name string, args ...string) *exec.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), testNetworkCommandTimeout)
cmd := exec.CommandContext(ctx, name, args...)
cmd.Cancel = func() error {
defer cancel()
return cmd.Process.Kill()
}
return cmd
}

// newTestIPTablesCommand builds an iptables command with the -w wait flag so it
// waits for the xtables lock rather than failing immediately.
func newTestIPTablesCommand(args ...string) *exec.Cmd {
fullArgs := make([]string, 0, len(args)+2)
fullArgs = append(fullArgs, "-w", testIPTablesWaitSeconds)
fullArgs = append(fullArgs, args...)
return exec.Command("iptables", fullArgs...)
return testNetworkCommand("iptables", fullArgs...)
}

// logTestNetworkErr surfaces a real cleanup failure to stderr. These helpers run
Expand Down Expand Up @@ -545,13 +562,13 @@ func cleanupTestNetworkArtifacts(bridgeName, subnetCIDR string) {
if subnetCIDR != "" && bridgeName != "" {
// The route may already be gone (linkdown cleanup, prior pass) — that is
// not a real failure, so only surface unexpected errors.
out, err := exec.Command("ip", "-4", "route", "del", subnetCIDR, "dev", bridgeName).CombinedOutput()
out, err := testNetworkCommand("ip", "-4", "route", "del", subnetCIDR, "dev", bridgeName).CombinedOutput()
if err != nil && !isNoSuchObjectErr(out) {
logTestNetworkErr(fmt.Sprintf("ip route del %s dev %s: %s", subnetCIDR, bridgeName, strings.TrimSpace(string(out))), err)
}
}
if bridgeName != "" {
out, err := exec.Command("ip", "link", "delete", bridgeName, "type", "bridge").CombinedOutput()
out, err := testNetworkCommand("ip", "link", "delete", bridgeName, "type", "bridge").CombinedOutput()
if err != nil && !isNoSuchObjectErr(out) {
logTestNetworkErr(fmt.Sprintf("ip link delete %s: %s", bridgeName, strings.TrimSpace(string(out))), err)
}
Expand Down
Loading