diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e2910379..c2d1eba05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,23 +90,57 @@ jobs: ! command -v mkfs.ext4 &> /dev/null || \ ! command -v iptables &> /dev/null || \ ! command -v qemu-system-x86_64 &> /dev/null || \ - ! qemu-system-x86_64 --version >/dev/null 2>&1; then + ! qemu-system-x86_64 --version >/dev/null 2>&1 || \ + ! command -v qemu-img &> /dev/null || \ + ! command -v swtpm &> /dev/null || \ + ! test -d /usr/share/OVMF; then apt_update_with_retry - timeout 300s sudo apt-get install -y erofs-utils e2fsprogs iptables qemu-system-x86 qemu-utils + timeout 300s sudo apt-get install -y erofs-utils e2fsprogs iptables ovmf qemu-system-x86 qemu-utils swtpm fi + if test -f /usr/share/OVMF/OVMF_CODE_4M.secboot.fd && test -f /usr/share/OVMF/OVMF_VARS_4M.ms.fd; then + ovmf_code=/usr/share/OVMF/OVMF_CODE_4M.secboot.fd + ovmf_vars=/usr/share/OVMF/OVMF_VARS_4M.ms.fd + elif test -f /usr/share/OVMF/OVMF_CODE.secboot.fd && test -f /usr/share/OVMF/OVMF_VARS.ms.fd; then + ovmf_code=/usr/share/OVMF/OVMF_CODE.secboot.fd + ovmf_vars=/usr/share/OVMF/OVMF_VARS.ms.fd + else + echo "Secure Boot OVMF firmware with Microsoft-enrolled variables is unavailable" >&2 + exit 1 + fi + echo "HYPEMAN_WINDOWS_OVMF_CODE=$ovmf_code" >> "$GITHUB_ENV" + echo "HYPEMAN_WINDOWS_OVMF_VARS=$ovmf_vars" >> "$GITHUB_ENV" go mod download - name: Verify Linux test toolchain run: | set -euo pipefail TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" - for bin in mkfs.erofs mkfs.ext4 iptables qemu-system-x86_64; do + for bin in mkfs.erofs mkfs.ext4 iptables qemu-img qemu-system-x86_64 swtpm; do if ! sudo env "PATH=$TEST_PATH" bash -lc "command -v '$bin' >/dev/null"; then echo "missing required binary under sudo PATH: $bin" exit 1 fi sudo env "PATH=$TEST_PATH" bash -lc "command -v '$bin'" done + test -f "$HYPEMAN_WINDOWS_OVMF_CODE" + test -f "$HYPEMAN_WINDOWS_OVMF_VARS" + + - name: Test Windows hypervisor primitives + run: | + TEST_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" + for attempt in 1 2 3; do + if sudo env \ + "PATH=$TEST_PATH" \ + "CI=true" \ + "HYPEMAN_RUN_WINDOWS_CONFIG_INTEGRATION=1" \ + "HYPEMAN_WINDOWS_OVMF_CODE=$HYPEMAN_WINDOWS_OVMF_CODE" \ + "HYPEMAN_WINDOWS_OVMF_VARS=$HYPEMAN_WINDOWS_OVMF_VARS" \ + go test -count=1 -run '^TestWindowsConfigIntegration$' -timeout 2m ./lib/hypervisor/qemu; then + exit 0 + fi + test "$attempt" = 3 || sleep 5 + done + exit 1 # Slash-command runs are maintainer-approved and need authenticated pulls # for images that are not covered by the prewarm cache. diff --git a/README.md b/README.md index ef1f0fe25..70d3ee66f 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,9 @@ hypeman logs --source vmm my-app # View Hypeman operational logs hypeman logs --source hypeman my-app + +# View software TPM logs for a TPM-backed QEMU guest +hypeman logs --source swtpm my-app ``` For all available commands, run `hypeman --help`. diff --git a/cmd/api/api/instances.go b/cmd/api/api/instances.go index 669d38633..c4c2dddb3 100644 --- a/cmd/api/api/instances.go +++ b/cmd/api/api/instances.go @@ -920,6 +920,8 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan source = instances.LogSourceVMM case oapi.Hypeman: source = instances.LogSourceHypeman + case oapi.Swtpm: + source = instances.LogSourceSWTPM } } diff --git a/lib/hypervisor/README.md b/lib/hypervisor/README.md index 719df4f5f..cb1cf9a35 100644 --- a/lib/hypervisor/README.md +++ b/lib/hypervisor/README.md @@ -37,6 +37,8 @@ if hv.Capabilities().SupportsSnapshot { } ``` +Capabilities also describe boot requirements such as UEFI firmware and TPM support. Guest compatibility is checked against these properties instead of hard-coding a hypervisor type. Resource requirements and image policy remain guest-level validation concerns. + ## Platform Differences ### Linux (Cloud Hypervisor, QEMU) diff --git a/lib/hypervisor/cloudhypervisor/process.go b/lib/hypervisor/cloudhypervisor/process.go index 14b306be1..824b19160 100644 --- a/lib/hypervisor/cloudhypervisor/process.go +++ b/lib/hypervisor/cloudhypervisor/process.go @@ -72,7 +72,9 @@ func NewStarter() *Starter { // Verify Starter implements the interface var _ hypervisor.VMStarter = (*Starter)(nil) -func (s *Starter) ValidateConfig(hypervisor.VMConfig) error { return nil } +func (s *Starter) ValidateConfig(config hypervisor.VMConfig) error { + return hypervisor.ValidateDirectRawConfig("cloud-hypervisor", config) +} // SocketName returns the socket filename for Cloud Hypervisor. func (s *Starter) SocketName() string { @@ -108,6 +110,9 @@ func (s *Starter) ResolveVersion(p *paths.Paths, requested string) (string, erro // StartVM launches Cloud Hypervisor, configures the VM, and boots it. // Returns the process ID and a Hypervisor client for subsequent operations. func (s *Starter) StartVM(ctx context.Context, p *paths.Paths, version string, socketPath string, config hypervisor.VMConfig) (int, hypervisor.Hypervisor, error) { + if err := s.ValidateConfig(config); err != nil { + return 0, nil, fmt.Errorf("validate cloud-hypervisor config: %w", err) + } log := logger.FromContext(ctx) // Validate version diff --git a/lib/hypervisor/config.go b/lib/hypervisor/config.go index 456775868..e46aa3446 100644 --- a/lib/hypervisor/config.go +++ b/lib/hypervisor/config.go @@ -27,7 +27,12 @@ type VMConfig struct { PCIDevices []string VGPUDevicePath string - // Boot configuration + // Boot configuration. Empty BootMode preserves the existing direct-kernel + // behavior for Linux callers. + BootMode BootMode + Firmware *FirmwareConfig + TPM *TPMConfig + KernelPath string InitrdPath string KernelArgs string @@ -55,9 +60,54 @@ type CPUTopology struct { Packages int } +type BootMode string + +const ( + BootModeDirect BootMode = "direct" + BootModeUEFI BootMode = "uefi" +) + +// EffectiveBootMode preserves direct Linux kernel boot for existing callers. +func (c VMConfig) EffectiveBootMode() BootMode { + if c.BootMode == "" { + return BootModeDirect + } + return c.BootMode +} + +// FirmwareConfig describes UEFI firmware files. CodePath is immutable firmware; +// VarsPath is per-instance writable variable storage. +type FirmwareConfig struct { + CodePath string + VarsPath string + SecureBoot bool +} + +// TPMConfig describes a per-instance software TPM 2.0 endpoint. +type TPMConfig struct { + SocketPath string + StateDir string +} + +type DiskFormat string + +const ( + DiskFormatRaw DiskFormat = "raw" + DiskFormatQCOW2 DiskFormat = "qcow2" +) + +// EffectiveFormat preserves raw disks for existing callers. +func (d DiskConfig) EffectiveFormat() DiskFormat { + if d.Format == "" { + return DiskFormatRaw + } + return d.Format +} + // DiskConfig represents a disk attached to the VM type DiskConfig struct { Path string + Format DiskFormat Readonly bool IOBps int64 // Sustained I/O rate limit in bytes/sec (0 = unlimited) IOBurstBps int64 // Burst I/O rate in bytes/sec (0 = same as IOBps) diff --git a/lib/hypervisor/config_validation.go b/lib/hypervisor/config_validation.go new file mode 100644 index 000000000..4419e2a01 --- /dev/null +++ b/lib/hypervisor/config_validation.go @@ -0,0 +1,57 @@ +package hypervisor + +import "fmt" + +// ValidateBootConfig validates boot and disk fields shared by hypervisor backends. +func ValidateBootConfig(cfg VMConfig) error { + switch cfg.EffectiveBootMode() { + case BootModeDirect: + if cfg.Firmware != nil { + return fmt.Errorf("direct boot cannot specify firmware") + } + if cfg.TPM != nil { + return fmt.Errorf("direct boot cannot specify a TPM") + } + case BootModeUEFI: + if cfg.Firmware == nil { + return fmt.Errorf("UEFI boot requires firmware") + } + if cfg.Firmware.CodePath == "" || cfg.Firmware.VarsPath == "" { + return fmt.Errorf("UEFI boot requires firmware code and variable storage paths") + } + if cfg.KernelPath != "" || cfg.InitrdPath != "" || cfg.KernelArgs != "" { + return fmt.Errorf("UEFI boot cannot specify a direct kernel, initrd, or kernel arguments") + } + if cfg.TPM != nil && (cfg.TPM.SocketPath == "" || cfg.TPM.StateDir == "") { + return fmt.Errorf("TPM requires socket and state directory paths") + } + default: + return fmt.Errorf("unsupported boot mode %q", cfg.BootMode) + } + + for i, disk := range cfg.Disks { + switch disk.EffectiveFormat() { + case DiskFormatRaw, DiskFormatQCOW2: + default: + return fmt.Errorf("disk %d has unsupported format %q", i, disk.Format) + } + } + return nil +} + +// ValidateDirectRawConfig preserves the Linux-only contract of backends that +// do not implement firmware boot or qcow2 disks. +func ValidateDirectRawConfig(backend string, cfg VMConfig) error { + if err := ValidateBootConfig(cfg); err != nil { + return err + } + if cfg.EffectiveBootMode() != BootModeDirect { + return fmt.Errorf("%s does not support %s boot", backend, cfg.EffectiveBootMode()) + } + for i, disk := range cfg.Disks { + if disk.EffectiveFormat() != DiskFormatRaw { + return fmt.Errorf("%s does not support disk %d format %q", backend, i, disk.EffectiveFormat()) + } + } + return nil +} diff --git a/lib/hypervisor/config_validation_test.go b/lib/hypervisor/config_validation_test.go new file mode 100644 index 000000000..8bfa71916 --- /dev/null +++ b/lib/hypervisor/config_validation_test.go @@ -0,0 +1,51 @@ +package hypervisor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateBootConfigPreservesDirectRawDefaults(t *testing.T) { + cfg := VMConfig{ + KernelPath: "/kernel", + Disks: []DiskConfig{{Path: "/rootfs"}}, + } + require.NoError(t, ValidateBootConfig(cfg)) + assert.Equal(t, BootModeDirect, cfg.EffectiveBootMode()) + assert.Equal(t, DiskFormatRaw, cfg.Disks[0].EffectiveFormat()) +} + +func TestValidateBootConfigUEFI(t *testing.T) { + valid := VMConfig{ + BootMode: BootModeUEFI, + Firmware: &FirmwareConfig{CodePath: "/ovmf/code", VarsPath: "/instance/vars"}, + TPM: &TPMConfig{SocketPath: "/instance/swtpm.sock", StateDir: "/instance/tpm"}, + Disks: []DiskConfig{{Path: "/instance/disk", Format: DiskFormatQCOW2}}, + } + require.NoError(t, ValidateBootConfig(valid)) + + tests := []struct { + name string + cfg VMConfig + }{ + {name: "missing firmware", cfg: VMConfig{BootMode: BootModeUEFI}}, + {name: "direct kernel", cfg: VMConfig{BootMode: BootModeUEFI, Firmware: valid.Firmware, KernelPath: "/kernel"}}, + {name: "incomplete TPM", cfg: VMConfig{BootMode: BootModeUEFI, Firmware: valid.Firmware, TPM: &TPMConfig{StateDir: "/state"}}}, + {name: "unknown disk", cfg: VMConfig{Disks: []DiskConfig{{Path: "/disk", Format: "vhdx"}}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Error(t, ValidateBootConfig(tt.cfg)) + }) + } +} + +func TestValidateDirectRawConfigRejectsFirmwareAndQCOW2(t *testing.T) { + uefi := VMConfig{BootMode: BootModeUEFI, Firmware: &FirmwareConfig{CodePath: "/code", VarsPath: "/vars"}} + assert.ErrorContains(t, ValidateDirectRawConfig("backend", uefi), "does not support uefi boot") + + qcow := VMConfig{Disks: []DiskConfig{{Path: "/disk", Format: DiskFormatQCOW2}}} + assert.ErrorContains(t, ValidateDirectRawConfig("backend", qcow), "does not support disk 0 format") +} diff --git a/lib/hypervisor/firecracker/process.go b/lib/hypervisor/firecracker/process.go index 01fd8ce2e..28c9268a6 100644 --- a/lib/hypervisor/firecracker/process.go +++ b/lib/hypervisor/firecracker/process.go @@ -58,7 +58,9 @@ func WithUFFDClient(client UFFDClient) StarterOption { var _ hypervisor.VMStarter = (*Starter)(nil) -func (s *Starter) ValidateConfig(hypervisor.VMConfig) error { return nil } +func (s *Starter) ValidateConfig(config hypervisor.VMConfig) error { + return hypervisor.ValidateDirectRawConfig("firecracker", config) +} func (s *Starter) SocketName() string { return "fc.sock" @@ -90,6 +92,9 @@ func (s *Starter) ResolveVersion(p *paths.Paths, requested string) (string, erro } func (s *Starter) StartVM(ctx context.Context, p *paths.Paths, version string, socketPath string, config hypervisor.VMConfig) (int, hypervisor.Hypervisor, error) { + if err := s.ValidateConfig(config); err != nil { + return 0, nil, fmt.Errorf("validate firecracker config: %w", err) + } processCtx, processSpan := hypervisor.StartProcessSpan(ctx, hypervisor.TypeFirecracker) pid, err := s.startProcess(processCtx, p, version, socketPath) hypervisor.FinishTraceSpan(processSpan, err) diff --git a/lib/hypervisor/hypervisor.go b/lib/hypervisor/hypervisor.go index 947eade20..da117d8b0 100644 --- a/lib/hypervisor/hypervisor.go +++ b/lib/hypervisor/hypervisor.go @@ -378,6 +378,12 @@ type Capabilities struct { // SupportsVsock indicates if vsock communication is available SupportsVsock bool + // SupportsUEFIBoot indicates if firmware boot is available. + SupportsUEFIBoot bool + + // SupportsTPM indicates if a software TPM can be attached at boot. + SupportsTPM bool + // SupportsGPUPassthrough indicates if PCI device passthrough is available SupportsGPUPassthrough bool diff --git a/lib/hypervisor/qemu/README.md b/lib/hypervisor/qemu/README.md index 7229345e6..d79c81113 100644 --- a/lib/hypervisor/qemu/README.md +++ b/lib/hypervisor/qemu/README.md @@ -4,6 +4,14 @@ The `qemu` backend uses `q35` on amd64 and `virt` on arm64. These architecture-n QEMU ships many other machine models, including versioned compatibility aliases and hardware-emulation boards that do not fit Hypeman's guest contract. Hypeman intentionally does not mirror that open-ended list in its API. If another model eventually provides a useful, supportable capability profile, expose it as another hypervisor backend with explicit lifecycle and device guarantees rather than as an unchecked machine-type string. +## Firmware boot and TPM ownership + +The standard amd64 `qemu` profile supports UEFI firmware boot, Secure Boot variable storage, qcow2 disks, and software TPM 2.0 devices. These requirements are represented as runtime capabilities so callers can validate a guest's requirements without branching on the QEMU type name. Direct-kernel profiles reject firmware and TPM configuration. + +Firmware code is immutable host input. Each instance receives its own writable NVRAM file and TPM state directory, which must be preserved together with the guest disk. Hypeman starts `swtpm` first because QEMU connects to its control socket as a client; launching both processes concurrently would race that required ordering. Once the socket exists, QEMU starts immediately. + +QEMU and `swtpm` run in detached process groups and keep running if Hypeman restarts. QEMU retains its TPM control connection, and `swtpm --terminate` exits when that connection closes. Startup cleanup owns both processes as one cleanup stack so a partial boot cannot leave either process behind. TPM output is available through the instance logs API with `source=swtpm`. + ## `qemu-microvm` The `qemu-microvm` backend uses QEMU's Linux amd64-only `microvm` board. Upstream registers this board only in the x86 system emulator; `qemu-system-aarch64` does not provide an equivalent `microvm` machine. Hypeman uses direct kernel boot, `ttyS0` serial logs, and virtio-mmio transport for disks, networking, vsock, and the optional balloon. diff --git a/lib/hypervisor/qemu/config.go b/lib/hypervisor/qemu/config.go index 24401c0f2..f00b1a5af 100644 --- a/lib/hypervisor/qemu/config.go +++ b/lib/hypervisor/qemu/config.go @@ -20,7 +20,11 @@ func buildArgs(cfg hypervisor.VMConfig, machine MachineType) []string { microvm := machine == MachineTypeMicroVM // Machine type with KVM acceleration (arch-specific when omitted). - args = append(args, "-machine", string(machine)+",accel=kvm") + machineArg := string(machine) + ",accel=kvm" + if cfg.Firmware != nil && cfg.Firmware.SecureBoot { + machineArg += ",smm=on" + } + args = append(args, "-machine", machineArg) if microvm { // Do not allow a host qemu.conf to add devices outside microvm's // documented eight virtio-mmio-device limit. @@ -51,6 +55,18 @@ func buildArgs(cfg hypervisor.VMConfig, machine MachineType) []string { args = append(args, "-device", strings.Join(balloonOpts, ",")) } + // Firmware boot. The code image is shared and immutable; variable storage is + // a per-instance writable copy. + if cfg.EffectiveBootMode() == hypervisor.BootModeUEFI { + args = append(args, + "-drive", fmt.Sprintf("if=pflash,format=raw,unit=0,file=%s,readonly=on", cfg.Firmware.CodePath), + "-drive", fmt.Sprintf("if=pflash,format=raw,unit=1,file=%s", cfg.Firmware.VarsPath), + ) + if cfg.Firmware.SecureBoot { + args = append(args, "-global", "driver=cfi.pflash01,property=secure,value=on") + } + } + // Kernel and initrd if cfg.KernelPath != "" { args = append(args, "-kernel", cfg.KernelPath) @@ -64,7 +80,7 @@ func buildArgs(cfg hypervisor.VMConfig, machine MachineType) []string { // Disk configuration for i, disk := range cfg.Disks { - driveOpts := fmt.Sprintf("file=%s,format=raw,if=none,id=drive%d", disk.Path, i) + driveOpts := fmt.Sprintf("file=%s,format=%s,if=none,id=drive%d", disk.Path, disk.EffectiveFormat(), i) if disk.Readonly { // Disable host-side file locking for shared readonly bases so multiple // VMs can boot concurrently from the same image without lock contention. @@ -80,6 +96,15 @@ func buildArgs(cfg hypervisor.VMConfig, machine MachineType) []string { args = append(args, "-device", fmt.Sprintf("%s,drive=drive%d", virtioDevice(microvm, "virtio-blk"), i)) } + // Software TPM 2.0. The swtpm process is started by Starter before QEMU. + if cfg.TPM != nil { + args = append(args, + "-chardev", fmt.Sprintf("socket,id=chrtpm,path=%s", cfg.TPM.SocketPath), + "-tpmdev", "emulator,id=tpm0,chardev=chrtpm", + "-device", "tpm-crb,tpmdev=tpm0", + ) + } + // Network configuration for i, net := range cfg.Networks { netdevOpts := fmt.Sprintf("tap,id=net%d,ifname=%s,script=no,downscript=no", i, net.TAPDevice) diff --git a/lib/hypervisor/qemu/config_test.go b/lib/hypervisor/qemu/config_test.go index 0e08e50cf..71e8080af 100644 --- a/lib/hypervisor/qemu/config_test.go +++ b/lib/hypervisor/qemu/config_test.go @@ -81,6 +81,35 @@ func TestBuildArgs_Disks(t *testing.T) { assert.Contains(t, args, "virtio-blk-pci,drive=drive1") } +func TestBuildArgs_UEFISecureBootTPMAndQCOW2(t *testing.T) { + cfg := hypervisor.VMConfig{ + VCPUs: 2, + MemoryBytes: 1024 * 1024 * 1024, + BootMode: hypervisor.BootModeUEFI, + Firmware: &hypervisor.FirmwareConfig{ + CodePath: "/firmware/OVMF_CODE.fd", + VarsPath: "/instance/OVMF_VARS.fd", + SecureBoot: true, + }, + TPM: &hypervisor.TPMConfig{ + SocketPath: "/instance/swtpm.sock", + StateDir: "/instance/tpm", + }, + Disks: []hypervisor.DiskConfig{{Path: "/instance/windows.qcow2", Format: hypervisor.DiskFormatQCOW2}}, + } + + args := buildArgs(cfg, MachineTypeQ35) + assert.Contains(t, args, "q35,accel=kvm,smm=on") + assert.Contains(t, args, "if=pflash,format=raw,unit=0,file=/firmware/OVMF_CODE.fd,readonly=on") + assert.Contains(t, args, "if=pflash,format=raw,unit=1,file=/instance/OVMF_VARS.fd") + assert.Contains(t, args, "driver=cfi.pflash01,property=secure,value=on") + assert.Contains(t, args, "file=/instance/windows.qcow2,format=qcow2,if=none,id=drive0") + assert.Contains(t, args, "socket,id=chrtpm,path=/instance/swtpm.sock") + assert.Contains(t, args, "emulator,id=tpm0,chardev=chrtpm") + assert.Contains(t, args, "tpm-crb,tpmdev=tpm0") + assert.NotContains(t, args, "-kernel") +} + func TestBuildArgs_Network(t *testing.T) { cfg := hypervisor.VMConfig{ VCPUs: 1, @@ -230,6 +259,25 @@ func TestBuildArgs_MicroVM(t *testing.T) { } } +func TestProfilesValidateFirmwareAndDiskFormats(t *testing.T) { + uefi := hypervisor.VMConfig{ + BootMode: hypervisor.BootModeUEFI, + Firmware: &hypervisor.FirmwareConfig{CodePath: "/code", VarsPath: "/vars"}, + Disks: []hypervisor.DiskConfig{{Path: "/disk", Format: hypervisor.DiskFormatQCOW2}}, + } + standard := StandardProfile{} + if standardMachineType() == MachineTypeQ35 { + assert.True(t, standard.capabilities().SupportsUEFIBoot) + assert.True(t, standard.capabilities().SupportsTPM) + assert.NoError(t, standard.validateConfig(uefi)) + } else { + assert.False(t, standard.capabilities().SupportsUEFIBoot) + assert.False(t, standard.capabilities().SupportsTPM) + assert.ErrorContains(t, standard.validateConfig(uefi), "does not support UEFI boot on this host") + } + assert.ErrorContains(t, MicroVMProfile{}.validateConfig(uefi), "does not support uefi boot") +} + func TestBuildArgs_GuestMemoryBalloon(t *testing.T) { cfg := hypervisor.VMConfig{ VCPUs: 1, diff --git a/lib/hypervisor/qemu/fork.go b/lib/hypervisor/qemu/fork.go index 1141b7f4d..4599c033e 100644 --- a/lib/hypervisor/qemu/fork.go +++ b/lib/hypervisor/qemu/fork.go @@ -89,6 +89,14 @@ func rewriteQEMUConfigPaths(cfg hypervisor.VMConfig, sourceDir, targetDir string cfg.VsockSocket = replace(cfg.VsockSocket) cfg.KernelPath = replace(cfg.KernelPath) cfg.InitrdPath = replace(cfg.InitrdPath) + if cfg.Firmware != nil { + cfg.Firmware.CodePath = replace(cfg.Firmware.CodePath) + cfg.Firmware.VarsPath = replace(cfg.Firmware.VarsPath) + } + if cfg.TPM != nil { + cfg.TPM.SocketPath = replace(cfg.TPM.SocketPath) + cfg.TPM.StateDir = replace(cfg.TPM.StateDir) + } return cfg } diff --git a/lib/hypervisor/qemu/fork_test.go b/lib/hypervisor/qemu/fork_test.go index 833b1fdc8..ae9272ff9 100644 --- a/lib/hypervisor/qemu/fork_test.go +++ b/lib/hypervisor/qemu/fork_test.go @@ -18,10 +18,10 @@ func TestPrepareFork_NoSnapshotPathIsNoOp(t *testing.T) { } func TestPrepareFork_RewritesSnapshotConfig(t *testing.T) { - if _, err := microVMMachineType(); err != nil { - t.Skipf("microvm is unavailable on this platform: %v", err) + if standardMachineType() != MachineTypeQ35 { + t.Skip("UEFI snapshot configuration requires q35") } - starter := NewMicroVMStarter() + starter := NewStarter() snapshotDir := t.TempDir() sourceDir := "/src/guest" @@ -32,9 +32,16 @@ func TestPrepareFork_RewritesSnapshotConfig(t *testing.T) { SerialLogPath: sourceDir + "/logs/app.log", VsockCID: 12345, VsockSocket: sourceDir + "/vsock/vsock.sock", - KernelPath: sourceDir + "/kernel/vmlinuz", - InitrdPath: sourceDir + "/kernel/initrd", - KernelArgs: "console=ttyS0 root=" + sourceDir + "/rootfs note=keep-" + sourceDir + "-as-substring", + BootMode: hypervisor.BootModeUEFI, + Firmware: &hypervisor.FirmwareConfig{ + CodePath: sourceDir + "/OVMF_CODE.fd", + VarsPath: sourceDir + "/OVMF_VARS.fd", + SecureBoot: true, + }, + TPM: &hypervisor.TPMConfig{ + SocketPath: sourceDir + "/swtpm.sock", + StateDir: sourceDir + "/tpm", + }, Disks: []hypervisor.DiskConfig{ {Path: sourceDir + "/overlay.raw"}, {Path: "/volumes/volume-data.raw"}, @@ -48,7 +55,7 @@ func TestPrepareFork_RewritesSnapshotConfig(t *testing.T) { }, }, } - require.NoError(t, saveVMConfig(snapshotDir, savedVMConfig{VMConfig: initial, MachineType: MachineTypeMicroVM, QEMUVersion: "8.2.0"})) + require.NoError(t, saveVMConfig(snapshotDir, savedVMConfig{VMConfig: initial, MachineType: MachineTypeQ35, QEMUVersion: "8.2.0"})) result, err := starter.PrepareFork(context.Background(), hypervisor.ForkPrepareRequest{ SnapshotConfigPath: filepath.Join(snapshotDir, "config.json"), @@ -70,14 +77,17 @@ func TestPrepareFork_RewritesSnapshotConfig(t *testing.T) { updated, err := loadVMConfig(snapshotDir) require.NoError(t, err) - assert.Equal(t, MachineTypeMicroVM, updated.MachineType) + assert.Equal(t, MachineTypeQ35, updated.MachineType) assert.Equal(t, "8.2.0", updated.QEMUVersion) assert.Equal(t, int64(54321), updated.VsockCID) assert.Equal(t, targetDir+"/vsock/fork-vsock.sock", updated.VsockSocket) assert.Equal(t, targetDir+"/logs/fork-app.log", updated.SerialLogPath) - assert.Equal(t, targetDir+"/kernel/vmlinuz", updated.KernelPath) - assert.Equal(t, targetDir+"/kernel/initrd", updated.InitrdPath) - assert.Equal(t, initial.KernelArgs, updated.KernelArgs) + require.NotNil(t, updated.Firmware) + assert.Equal(t, targetDir+"/OVMF_CODE.fd", updated.Firmware.CodePath) + assert.Equal(t, targetDir+"/OVMF_VARS.fd", updated.Firmware.VarsPath) + require.NotNil(t, updated.TPM) + assert.Equal(t, targetDir+"/swtpm.sock", updated.TPM.SocketPath) + assert.Equal(t, targetDir+"/tpm", updated.TPM.StateDir) assert.Equal(t, targetDir+"/overlay.raw", updated.Disks[0].Path) assert.Equal(t, "/volumes/volume-data.raw", updated.Disks[1].Path, "non-instance paths should remain unchanged") require.Len(t, updated.Networks, 1) @@ -85,4 +95,19 @@ func TestPrepareFork_RewritesSnapshotConfig(t *testing.T) { assert.Equal(t, "10.100.20.20", updated.Networks[0].IP) assert.Equal(t, "02:00:00:dd:ee:ff", updated.Networks[0].MAC) assert.Equal(t, "255.255.0.0", updated.Networks[0].Netmask) + require.NoError(t, starter.profile.validateConfig(updated.VMConfig)) +} + +func TestRewriteQEMUConfigPathsDirectBoot(t *testing.T) { + sourceDir := "/src/guest" + targetDir := "/dst/guest" + updated := rewriteQEMUConfigPaths(hypervisor.VMConfig{ + KernelPath: sourceDir + "/kernel/vmlinuz", + InitrdPath: sourceDir + "/kernel/initrd", + KernelArgs: "console=ttyS0 root=" + sourceDir + "/rootfs", + }, sourceDir, targetDir) + + assert.Equal(t, targetDir+"/kernel/vmlinuz", updated.KernelPath) + assert.Equal(t, targetDir+"/kernel/initrd", updated.InitrdPath) + assert.Equal(t, "console=ttyS0 root="+sourceDir+"/rootfs", updated.KernelArgs) } diff --git a/lib/hypervisor/qemu/process.go b/lib/hypervisor/qemu/process.go index c02c5beef..c3f59e8e5 100644 --- a/lib/hypervisor/qemu/process.go +++ b/lib/hypervisor/qemu/process.go @@ -28,8 +28,9 @@ import ( // Timeout constants for QEMU operations const ( - // socketWaitTimeout is how long to wait for QMP socket to become available after process start - socketWaitTimeout = 10 * time.Second + // qemuSocketWaitTimeout bounds QMP startup on a heavily loaded host. Process + // exit is checked on every poll, so deterministic startup failures return early. + qemuSocketWaitTimeout = 30 * time.Second // migrationTimeout is how long to wait for migration to complete migrationTimeout = 30 * time.Second @@ -365,7 +366,7 @@ func (s *Starter) startQEMUProcess(ctx context.Context, p *paths.Paths, version // Wait for socket to be ready socketWaitStart := time.Now() - if err := waitForSocketOrExit(socketPath, socketWaitTimeout, proc); err != nil { + if err := waitForSocketOrExit(socketPath, qemuSocketWaitTimeout, proc); err != nil { processSpan.RecordError(err) processSpan.SetStatus(codes.Error, err.Error()) cu.Clean() @@ -425,6 +426,25 @@ func (s *Starter) validateSnapshotMachineType(stored MachineType) (MachineType, return expected, nil } +func (s *Starter) startConfiguredProcess(ctx context.Context, p *paths.Paths, version, socketPath string, config hypervisor.VMConfig, args []string) (int, *QEMU, *cleanup.Cleanup, error) { + cu := cleanup.Make(func() {}) + tpmProcess, err := startSWTPM(config.TPM, filepath.Dir(socketPath)) + if err != nil { + return 0, nil, nil, err + } + if tpmProcess != nil { + cu.Add(tpmProcess.cleanup) + } + + pid, hv, qemuCleanup, err := s.startQEMUProcess(ctx, p, version, socketPath, args) + if err != nil { + cu.Clean() + return 0, nil, nil, err + } + cu.Add(qemuCleanup.Clean) + return pid, hv, &cu, nil +} + // StartVM launches QEMU with the VM configuration and returns a Hypervisor client. // QEMU receives all configuration via command-line arguments at process start. func (s *Starter) StartVM(ctx context.Context, p *paths.Paths, version string, socketPath string, config hypervisor.VMConfig) (int, hypervisor.Hypervisor, error) { @@ -474,7 +494,7 @@ func (s *Starter) StartVM(ctx context.Context, p *paths.Paths, version string, s // Build command arguments: QMP socket + VM configuration args := buildQMPArgs(socketPath) args = append(args, buildArgs(attempt, machineType)...) - pid, hv, cu, err = s.startQEMUProcess(ctx, p, version, socketPath, args) + pid, hv, cu, err = s.startConfiguredProcess(ctx, p, version, socketPath, attempt, args) if err == nil { booted = attempt started = true @@ -609,7 +629,7 @@ func (s *Starter) RestoreVM(ctx context.Context, p *paths.Paths, version string, incomingURI := "exec:cat < " + memoryFile args = append(args, "-incoming", incomingURI) - pid, hv, cu, err := s.startQEMUProcess(ctx, p, version, socketPath, args) + pid, hv, cu, err := s.startConfiguredProcess(ctx, p, version, socketPath, config, args) if err != nil { return 0, nil, err } diff --git a/lib/hypervisor/qemu/profile.go b/lib/hypervisor/qemu/profile.go index 2d61cd6fe..0652551f9 100644 --- a/lib/hypervisor/qemu/profile.go +++ b/lib/hypervisor/qemu/profile.go @@ -25,11 +25,17 @@ func (StandardProfile) machineType() (MachineType, error) { return standardMachineType(), nil } func (StandardProfile) capabilities() hypervisor.Capabilities { - return qemuCapabilities(true) + supportsFirmware := standardMachineType() == MachineTypeQ35 + return qemuCapabilities(true, supportsFirmware) } -func (StandardProfile) validateConfig(hypervisor.VMConfig) error { return nil } -func (StandardProfile) requiresStoredMachineType() bool { return false } -func (StandardProfile) requiresStoredVersion() bool { return false } +func (p StandardProfile) validateConfig(cfg hypervisor.VMConfig) error { + if err := hypervisor.ValidateBootConfig(cfg); err != nil { + return err + } + return validateProfileCapabilities(p.hypervisorType(), p.capabilities(), cfg) +} +func (StandardProfile) requiresStoredMachineType() bool { return false } +func (StandardProfile) requiresStoredVersion() bool { return false } // MicroVMProfile selects QEMU's minimal x86 microvm board and enforces its // virtio-mmio device contract. @@ -40,9 +46,12 @@ func (MicroVMProfile) machineType() (MachineType, error) { return microVMMachineType() } func (MicroVMProfile) capabilities() hypervisor.Capabilities { - return qemuCapabilities(false) + return qemuCapabilities(false, false) } func (MicroVMProfile) validateConfig(cfg hypervisor.VMConfig) error { + if err := hypervisor.ValidateDirectRawConfig("qemu-microvm", cfg); err != nil { + return err + } if cfg.HotplugBytes > 0 { return fmt.Errorf("microvm does not support hotplug memory") } @@ -65,7 +74,17 @@ func (MicroVMProfile) validateConfig(cfg hypervisor.VMConfig) error { func (MicroVMProfile) requiresStoredMachineType() bool { return true } func (MicroVMProfile) requiresStoredVersion() bool { return true } -func qemuCapabilities(supportsPCI bool) hypervisor.Capabilities { +func validateProfileCapabilities(name hypervisor.Type, caps hypervisor.Capabilities, cfg hypervisor.VMConfig) error { + if cfg.EffectiveBootMode() == hypervisor.BootModeUEFI && !caps.SupportsUEFIBoot { + return fmt.Errorf("%s does not support UEFI boot on this host", name) + } + if cfg.TPM != nil && !caps.SupportsTPM { + return fmt.Errorf("%s does not support TPM devices", name) + } + return nil +} + +func qemuCapabilities(supportsPCI, supportsFirmware bool) hypervisor.Capabilities { return hypervisor.Capabilities{ SupportsSnapshot: true, // PrepareFork rewrites the saved QEMU VM config for forks (fork.go); @@ -75,6 +94,8 @@ func qemuCapabilities(supportsPCI bool) hypervisor.Capabilities { SupportsBalloonControl: true, SupportsPause: true, SupportsVsock: true, + SupportsUEFIBoot: supportsFirmware, + SupportsTPM: supportsFirmware, SupportsGPUPassthrough: supportsPCI, SupportsDiskIOLimit: true, SupportsGracefulVMMShutdown: true, diff --git a/lib/hypervisor/qemu/swtpm.go b/lib/hypervisor/qemu/swtpm.go new file mode 100644 index 000000000..97aa03ec0 --- /dev/null +++ b/lib/hypervisor/qemu/swtpm.go @@ -0,0 +1,185 @@ +package qemu + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" +) + +const swtpmSocketWaitTimeout = 30 * time.Second + +type swtpmProcessRecord struct { + pid int + identity string +} + +func startSWTPM(config *hypervisor.TPMConfig, instanceDir string) (*startedProcess, error) { + if config == nil { + return nil, nil + } + + deadline := time.Now().Add(swtpmSocketWaitTimeout) + logsDir := filepath.Join(instanceDir, "logs") + if err := os.MkdirAll(logsDir, 0755); err != nil { + return nil, fmt.Errorf("create swtpm logs directory: %w", err) + } + processRecordPath := filepath.Join(logsDir, "swtpm.pid") + if err := waitForPreviousSWTPM(config, processRecordPath, deadline); err != nil { + return nil, err + } + + binary, err := exec.LookPath("swtpm") + if err != nil { + return nil, fmt.Errorf("find swtpm: %w", err) + } + if err := os.MkdirAll(config.StateDir, 0700); err != nil { + return nil, fmt.Errorf("create swtpm state directory: %w", err) + } + if err := os.MkdirAll(filepath.Dir(config.SocketPath), 0755); err != nil { + return nil, fmt.Errorf("create swtpm socket directory: %w", err) + } + if err := os.Remove(config.SocketPath); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("remove stale swtpm socket: %w", err) + } + + logPath := filepath.Join(logsDir, "swtpm.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, fmt.Errorf("create swtpm log: %w", err) + } + defer logFile.Close() + + cmd := exec.Command(binary, + "socket", + "--tpm2", + "--tpmstate", "dir="+config.StateDir, + "--ctrl", "type=unixio,path="+config.SocketPath, + "--terminate", + ) + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + proc, err := startManagedProcess(cmd, config.SocketPath) + if err != nil { + return nil, fmt.Errorf("start swtpm: %w", err) + } + if err := writeSWTPMProcessRecord(processRecordPath, proc.pid); err != nil { + proc.cleanup() + return nil, err + } + if err := waitForSocketFileOrExit(config.SocketPath, time.Until(deadline), proc); err != nil { + proc.cleanup() + _ = os.Remove(processRecordPath) + if logData, readErr := os.ReadFile(logPath); readErr == nil && len(logData) > 0 { + const maxLogBytes = 32 << 10 + if len(logData) > maxLogBytes { + logData = logData[len(logData)-maxLogBytes:] + } + return nil, fmt.Errorf("wait for swtpm: %w; swtpm.log: %s", err, logData) + } + return nil, fmt.Errorf("wait for swtpm: %w", err) + } + return proc, nil +} + +func waitForPreviousSWTPM(config *hypervisor.TPMConfig, recordPath string, deadline time.Time) error { + record, err := readSWTPMProcessRecord(recordPath) + if err == nil { + identity, alive, inspectErr := swtpmProcessIdentity(record.pid) + if inspectErr != nil { + return fmt.Errorf("inspect previous swtpm process %d: %w", record.pid, inspectErr) + } + if alive && identity == record.identity { + return waitForSWTPMExit(record, recordPath, deadline) + } + _ = os.Remove(recordPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("read previous swtpm process: %w", err) + } + + record, found, err := discoverSWTPMProcess(config) + if err != nil { + return fmt.Errorf("reconcile previous swtpm process: %w", err) + } + if !found { + return nil + } + return waitForSWTPMExit(record, recordPath, deadline) +} + +func waitForSWTPMExit(record swtpmProcessRecord, recordPath string, deadline time.Time) error { + for { + identity, alive, err := swtpmProcessIdentity(record.pid) + if err != nil { + return fmt.Errorf("inspect previous swtpm process %d: %w", record.pid, err) + } + if !alive || identity != record.identity { + _ = os.Remove(recordPath) + return nil + } + if !time.Now().Before(deadline) { + return fmt.Errorf("timeout waiting for previous swtpm process %d", record.pid) + } + time.Sleep(socketPollInterval) + } +} + +func readSWTPMProcessRecord(path string) (swtpmProcessRecord, error) { + data, err := os.ReadFile(path) + if err != nil { + return swtpmProcessRecord{}, err + } + fields := strings.Fields(string(data)) + if len(fields) != 2 { + return swtpmProcessRecord{}, fmt.Errorf("invalid process record %q", strings.TrimSpace(string(data))) + } + pid, err := strconv.Atoi(fields[0]) + if err != nil || pid <= 0 { + return swtpmProcessRecord{}, fmt.Errorf("invalid process id %q", fields[0]) + } + return swtpmProcessRecord{pid: pid, identity: fields[1]}, nil +} + +func writeSWTPMProcessRecord(path string, pid int) error { + identity, alive, err := swtpmProcessIdentity(pid) + if err != nil { + return fmt.Errorf("inspect new swtpm process %d: %w", pid, err) + } + if !alive { + return fmt.Errorf("new swtpm process %d exited before recording", pid) + } + data := []byte(fmt.Sprintf("%d %s\n", pid, identity)) + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { + return fmt.Errorf("write swtpm process record: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("publish swtpm process record: %w", err) + } + return nil +} + +// A connect probe would make swtpm treat the probe as its control client; with +// --terminate, closing that probe would stop swtpm before QEMU connects. +func waitForSocketFileOrExit(socketPath string, timeout time.Duration, proc *startedProcess) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if info, err := os.Stat(socketPath); err == nil && info.Mode()&os.ModeSocket != 0 { + return nil + } + if waitErr, exited := proc.checkExited(); exited { + return fmt.Errorf("swtpm exited early: %w", waitErr) + } + time.Sleep(socketPollInterval) + } + return fmt.Errorf("timeout waiting for socket") +} diff --git a/lib/hypervisor/qemu/swtpm_process_linux.go b/lib/hypervisor/qemu/swtpm_process_linux.go new file mode 100644 index 000000000..357a05b30 --- /dev/null +++ b/lib/hypervisor/qemu/swtpm_process_linux.go @@ -0,0 +1,110 @@ +package qemu + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/kernel/hypeman/lib/hypervisor" +) + +func discoverSWTPMProcess(config *hypervisor.TPMConfig) (swtpmProcessRecord, bool, error) { + pid, err := hypervisor.ResolveProcessPID(config.SocketPath) + if err == nil { + matches, err := swtpmProcessMatchesConfig(pid, config) + if err != nil { + return swtpmProcessRecord{}, false, err + } + if !matches { + return swtpmProcessRecord{}, false, fmt.Errorf("process %d owns %s but is not the expected swtpm", pid, config.SocketPath) + } + return swtpmProcessRecordForPID(pid) + } + if !errors.Is(err, hypervisor.ErrNoOwningProcess) { + return swtpmProcessRecord{}, false, err + } + + entries, err := os.ReadDir("/proc") + if err != nil { + return swtpmProcessRecord{}, false, err + } + var found swtpmProcessRecord + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + matches, err := swtpmProcessMatchesConfig(pid, config) + if os.IsNotExist(err) { + continue + } + if err != nil { + return swtpmProcessRecord{}, false, err + } + if !matches { + continue + } + record, alive, err := swtpmProcessRecordForPID(pid) + if err != nil { + return swtpmProcessRecord{}, false, err + } + if !alive { + continue + } + if found.pid != 0 { + return swtpmProcessRecord{}, false, fmt.Errorf("multiple swtpm processes use %s", config.SocketPath) + } + found = record + } + return found, found.pid != 0, nil +} + +func swtpmProcessMatchesConfig(pid int, config *hypervisor.TPMConfig) (bool, error) { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) + if err != nil { + return false, err + } + args := bytes.Split(bytes.TrimRight(data, "\x00"), []byte{0}) + if len(args) == 0 || filepath.Base(string(args[0])) != "swtpm" { + return false, nil + } + stateArg := "dir=" + config.StateDir + controlArg := "type=unixio,path=" + config.SocketPath + var hasState, hasControl bool + for _, arg := range args[1:] { + hasState = hasState || string(arg) == stateArg + hasControl = hasControl || string(arg) == controlArg + } + return hasState && hasControl, nil +} + +func swtpmProcessRecordForPID(pid int) (swtpmProcessRecord, bool, error) { + identity, alive, err := swtpmProcessIdentity(pid) + return swtpmProcessRecord{pid: pid, identity: identity}, alive, err +} + +func swtpmProcessIdentity(pid int) (string, bool, error) { + data, err := os.ReadFile(filepath.Join("/proc", fmt.Sprint(pid), "stat")) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, err + } + + // The command name is parenthesized and may contain spaces. Fields after + // the final ')' begin at stat field 3; process start time is field 22. + end := bytes.LastIndexByte(data, ')') + if end < 0 { + return "", false, fmt.Errorf("invalid /proc/%d/stat", pid) + } + fields := strings.Fields(string(data[end+1:])) + if len(fields) <= 19 { + return "", false, fmt.Errorf("incomplete /proc/%d/stat", pid) + } + return fields[19], true, nil +} diff --git a/lib/hypervisor/qemu/swtpm_process_other.go b/lib/hypervisor/qemu/swtpm_process_other.go new file mode 100644 index 000000000..7d714aae4 --- /dev/null +++ b/lib/hypervisor/qemu/swtpm_process_other.go @@ -0,0 +1,37 @@ +//go:build !linux + +package qemu + +import ( + "errors" + "fmt" + "os" + "strconv" + "syscall" + + "github.com/kernel/hypeman/lib/hypervisor" +) + +func discoverSWTPMProcess(config *hypervisor.TPMConfig) (swtpmProcessRecord, bool, error) { + if _, err := os.Lstat(config.SocketPath); err == nil { + return swtpmProcessRecord{}, false, fmt.Errorf("cannot verify owner of existing socket %s on this platform", config.SocketPath) + } else if !os.IsNotExist(err) { + return swtpmProcessRecord{}, false, err + } + return swtpmProcessRecord{}, false, nil +} + +func swtpmProcessIdentity(pid int) (string, bool, error) { + process, err := os.FindProcess(pid) + if err != nil { + return "", false, err + } + err = process.Signal(syscall.Signal(0)) + if err == nil || errors.Is(err, syscall.EPERM) { + return strconv.Itoa(pid), true, nil + } + if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { + return "", false, nil + } + return "", false, err +} diff --git a/lib/hypervisor/qemu/windows_config_integration_linux_test.go b/lib/hypervisor/qemu/windows_config_integration_linux_test.go new file mode 100644 index 000000000..29ee28d72 --- /dev/null +++ b/lib/hypervisor/qemu/windows_config_integration_linux_test.go @@ -0,0 +1,158 @@ +//go:build linux && amd64 + +package qemu + +import ( + "context" + "io/fs" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/kernel/hypeman/lib/hypervisor" + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +func requireWindowsConfigDependency(t *testing.T, path, description string) string { + t.Helper() + if path != "" { + if _, err := os.Stat(path); err == nil { + return path + } + } + if os.Getenv("CI") == "true" { + t.Fatalf("required Windows config integration dependency is missing: %s (%s)", description, path) + } + t.Skipf("%s is unavailable", description) + return "" +} + +func TestWindowsConfigIntegration(t *testing.T) { + if os.Getenv("HYPEMAN_RUN_WINDOWS_CONFIG_INTEGRATION") != "1" { + t.Skip("run by the dedicated Windows config CI gate") + } + requireWindowsConfigDependency(t, "/dev/kvm", "KVM") + if _, err := exec.LookPath("qemu-system-x86_64"); err != nil { + requireWindowsConfigDependency(t, "", "qemu-system-x86_64") + } + if _, err := exec.LookPath("qemu-img"); err != nil { + requireWindowsConfigDependency(t, "", "qemu-img") + } + if _, err := exec.LookPath("swtpm"); err != nil { + requireWindowsConfigDependency(t, "", "swtpm") + } + + codePath := os.Getenv("HYPEMAN_WINDOWS_OVMF_CODE") + if codePath == "" { + codePath = "/usr/share/OVMF/OVMF_CODE_4M.secboot.fd" + } + varsTemplate := os.Getenv("HYPEMAN_WINDOWS_OVMF_VARS") + if varsTemplate == "" { + varsTemplate = "/usr/share/OVMF/OVMF_VARS_4M.ms.fd" + } + requireWindowsConfigDependency(t, codePath, "Secure Boot OVMF code") + requireWindowsConfigDependency(t, varsTemplate, "Microsoft-enrolled OVMF variables") + + dir, err := os.MkdirTemp("/tmp", "hypeman-win-config-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(dir)) }) + + varsData, err := os.ReadFile(varsTemplate) + require.NoError(t, err) + varsPath := filepath.Join(dir, "OVMF_VARS.fd") + require.NoError(t, os.WriteFile(varsPath, varsData, 0600)) + + basePath := filepath.Join(dir, "base.raw") + base, err := os.Create(basePath) + require.NoError(t, err) + require.NoError(t, base.Truncate(64<<20)) + require.NoError(t, base.Close()) + + diskPath := filepath.Join(dir, "instance.qcow2") + output, err := exec.Command("qemu-img", "create", "-f", "qcow2", "-F", "raw", "-b", basePath, diskPath).CombinedOutput() + require.NoError(t, err, "qemu-img create: %s", output) + + socketPath := filepath.Join(dir, "qemu.sock") + tpmSocket := filepath.Join(dir, "swtpm.sock") + tpmState := filepath.Join(dir, "tpm") + config := hypervisor.VMConfig{ + VCPUs: 1, + MemoryBytes: 512 << 20, + BootMode: hypervisor.BootModeUEFI, + Firmware: &hypervisor.FirmwareConfig{ + CodePath: codePath, + VarsPath: varsPath, + SecureBoot: true, + }, + TPM: &hypervisor.TPMConfig{SocketPath: tpmSocket, StateDir: tpmState}, + Disks: []hypervisor.DiskConfig{{Path: diskPath, Format: hypervisor.DiskFormatQCOW2}}, + } + + starter := NewStarter() + processRecordPath := filepath.Join(dir, "logs", "swtpm.pid") + start := func() (hypervisor.Hypervisor, swtpmProcessRecord) { + pid, vm, err := starter.StartVM(context.Background(), paths.New(dir), "", socketPath, config) + require.NoError(t, err) + t.Cleanup(func() { _ = vm.Shutdown(context.Background()) }) + require.Positive(t, pid) + info, err := vm.GetVMInfo(context.Background()) + require.NoError(t, err) + require.Equal(t, hypervisor.StateRunning, info.State) + record, err := readSWTPMProcessRecord(processRecordPath) + require.NoError(t, err) + return vm, record + } + shutdown := func(vm hypervisor.Hypervisor) { + require.NoError(t, vm.Shutdown(context.Background())) + require.Eventually(t, func() bool { + _, err := os.Stat(socketPath) + return os.IsNotExist(err) + }, 5*time.Second, 20*time.Millisecond) + } + + firstVM, firstTPM := start() + info, err := os.Stat(diskPath) + require.NoError(t, err) + require.Positive(t, info.Size(), "qcow2 overlay must contain metadata") + + var stateFiles int + require.NoError(t, filepath.WalkDir(tpmState, func(path string, entry fs.DirEntry, err error) error { + if err == nil && !entry.IsDir() { + stateFiles++ + } + return err + })) + require.Positive(t, stateFiles, "swtpm must persist TPM 2.0 state") + require.FileExists(t, varsPath) + + require.NoError(t, syscall.Kill(firstTPM.pid, syscall.SIGSTOP)) + require.NoError(t, os.Remove(processRecordPath), "simulate restart before the swtpm PID record was published") + t.Cleanup(func() { + identity, alive, _ := swtpmProcessIdentity(firstTPM.pid) + if alive && identity == firstTPM.identity { + _ = syscall.Kill(firstTPM.pid, syscall.SIGCONT) + } + }) + resumeResult := make(chan error, 1) + go func() { + time.Sleep(2 * time.Second) + resumeResult <- syscall.Kill(firstTPM.pid, syscall.SIGCONT) + }() + require.NoError(t, firstVM.Shutdown(context.Background())) + identity, alive, err := swtpmProcessIdentity(firstTPM.pid) + require.NoError(t, err) + require.True(t, alive && identity == firstTPM.identity, "previous swtpm must still be exiting when restart begins") + + secondVM, secondTPM := start() + require.NoError(t, <-resumeResult) + identity, alive, err = swtpmProcessIdentity(firstTPM.pid) + require.NoError(t, err) + require.False(t, alive && identity == firstTPM.identity, "replacement started before the previous swtpm exited") + require.NotEqual(t, firstTPM, secondTPM) + shutdown(secondVM) + require.FileExists(t, varsPath) +} diff --git a/lib/hypervisor/vz/starter.go b/lib/hypervisor/vz/starter.go index d62026b53..ec5132fd4 100644 --- a/lib/hypervisor/vz/starter.go +++ b/lib/hypervisor/vz/starter.go @@ -105,7 +105,9 @@ func NewStarter() *Starter { var _ hypervisor.VMStarter = (*Starter)(nil) -func (s *Starter) ValidateConfig(hypervisor.VMConfig) error { return nil } +func (s *Starter) ValidateConfig(config hypervisor.VMConfig) error { + return hypervisor.ValidateDirectRawConfig("vz", config) +} func (s *Starter) SocketName() string { return "vz.sock" @@ -130,6 +132,9 @@ func (s *Starter) ResolveVersion(p *paths.Paths, requested string) (string, erro // StartVM spawns a vz-shim subprocess to host the VM. func (s *Starter) StartVM(ctx context.Context, p *paths.Paths, version string, socketPath string, config hypervisor.VMConfig) (int, hypervisor.Hypervisor, error) { + if err := s.ValidateConfig(config); err != nil { + return 0, nil, fmt.Errorf("validate vz config: %w", err) + } shimConfig := buildShimConfigFromVMConfig(config, socketPath) return s.startShim(ctx, p, version, shimConfig, 30*time.Second) } diff --git a/lib/instances/logs.go b/lib/instances/logs.go index f10e8537d..54b280f81 100644 --- a/lib/instances/logs.go +++ b/lib/instances/logs.go @@ -24,6 +24,8 @@ const ( LogSourceVMM LogSource = "vmm" // LogSourceHypeman is the hypeman operations log LogSourceHypeman LogSource = "hypeman" + // LogSourceSWTPM is the software TPM log for TPM-backed QEMU guests. + LogSourceSWTPM LogSource = "swtpm" ) // ErrTailNotFound is returned when the tail command is not available @@ -73,6 +75,8 @@ func (m *manager) streamInstanceLogs(ctx context.Context, id string, tail int, f logPath = m.paths.InstanceVMMLog(id) case LogSourceHypeman: logPath = m.paths.InstanceHypemanLog(id) + case LogSourceSWTPM: + logPath = m.paths.InstanceSWTPMLog(id) default: // Default to app log for backwards compatibility logPath = m.paths.InstanceAppLog(id) diff --git a/lib/instances/manager.go b/lib/instances/manager.go index bc23fdf74..a3be16c57 100644 --- a/lib/instances/manager.go +++ b/lib/instances/manager.go @@ -814,7 +814,7 @@ func (m *manager) StreamInstanceLogs(ctx context.Context, id string, tail int, f return m.streamInstanceLogs(ctx, id, tail, follow, source) } -// RotateLogs rotates all instance logs (app, vmm, hypeman) that exceed maxBytes +// RotateLogs rotates all instance logs that exceed maxBytes func (m *manager) RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) error { instances, err := m.listInstances(ctx) if err != nil { @@ -823,11 +823,11 @@ func (m *manager) RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) var lastErr error for _, inst := range instances { - // Rotate all three log types logPaths := []string{ m.paths.InstanceAppLog(inst.Id), m.paths.InstanceVMMLog(inst.Id), m.paths.InstanceHypemanLog(inst.Id), + m.paths.InstanceSWTPMLog(inst.Id), } for _, logPath := range logPaths { if err := rotateLogIfNeeded(logPath, maxBytes, maxFiles); err != nil { diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index aaf55b62c..e49c43d87 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -278,6 +278,7 @@ const ( const ( App GetInstanceLogsParamsSource = "app" Hypeman GetInstanceLogsParamsSource = "hypeman" + Swtpm GetInstanceLogsParamsSource = "swtpm" Vmm GetInstanceLogsParamsSource = "vmm" ) @@ -19561,55 +19562,55 @@ var swaggerSpec = []string{ "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/paths/paths.go b/lib/paths/paths.go index 814dc1432..03ef35351 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -270,6 +270,11 @@ func (p *Paths) InstanceHypemanLog(id string) string { return filepath.Join(p.InstanceLogs(id), "hypeman.log") } +// InstanceSWTPMLog returns the path to the instance software TPM log. +func (p *Paths) InstanceSWTPMLog(id string) string { + return filepath.Join(p.InstanceLogs(id), "swtpm.log") +} + // InstanceSnapshots returns the path to instance snapshots directory. func (p *Paths) InstanceSnapshots(id string) string { return filepath.Join(p.InstanceDir(id), "snapshots") diff --git a/openapi.yaml b/openapi.yaml index 4d5830c2e..e7fa85af9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3672,7 +3672,7 @@ paths: required: false schema: type: string - enum: [app, vmm, hypeman] + enum: [app, vmm, hypeman, swtpm] default: app description: | Log source to stream: diff --git a/stainless/custom-code/go/2026-08-21T19-11-19-339Z-custom-code.json b/stainless/custom-code/go/2026-08-21T19-11-19-339Z-custom-code.json index 2e8ad9ebb..6722ab829 100644 --- a/stainless/custom-code/go/2026-08-21T19-11-19-339Z-custom-code.json +++ b/stainless/custom-code/go/2026-08-21T19-11-19-339Z-custom-code.json @@ -1,6 +1,6 @@ { - "base": "05a212dc4688777a3d1df23a3b182487055e77dd", - "integrated": "013dac97bc8391d82f4b583f1a579c3024f23f88", + "base": "c3728334ac2d97fdfb98eef13aaf5e4adc5ec2cf", + "integrated": "349055e23cca743b53b46b3f3f90b2df8d9403b1", "filename": "2026-08-21T19-11-19-339Z-custom-code.json", "branch": "main" } diff --git a/stainless/custom-code/python/2026-08-17T14-04-38-940Z-custom-code.json b/stainless/custom-code/python/2026-08-17T14-04-38-940Z-custom-code.json index 45791a1af..1c9c8c40c 100644 --- a/stainless/custom-code/python/2026-08-17T14-04-38-940Z-custom-code.json +++ b/stainless/custom-code/python/2026-08-17T14-04-38-940Z-custom-code.json @@ -1,6 +1,6 @@ { - "base": "948291d549091ca6bab9d0ac22a85aeeb90e22be", - "integrated": "cfc7ca84d29e364889896f01c6e10cedd3ec0541", + "base": "bd342f8108e68c72dfc0221717e95601d383c3bf", + "integrated": "73d785e97e7c161edb8387b64d5cc12d5bdec5c2", "filename": "2026-08-17T14-04-38-940Z-custom-code.json", "branch": "main" } diff --git a/stainless/custom-code/typescript/2026-08-21T19-11-20-278Z-custom-code.json b/stainless/custom-code/typescript/2026-08-21T19-11-20-278Z-custom-code.json index d2a1c684e..e2c0da5e9 100644 --- a/stainless/custom-code/typescript/2026-08-21T19-11-20-278Z-custom-code.json +++ b/stainless/custom-code/typescript/2026-08-21T19-11-20-278Z-custom-code.json @@ -1,6 +1,6 @@ { - "base": "3339edfd85d573ddf79daa9477092f5f4eaa0dec", - "integrated": "4cd115310104722fe9ce8ab2b307d535c16d25dd", + "base": "f7fc13de29e8c2112fcd25e3c2f4f5b1a648cd59", + "integrated": "389d2bfe9f575dd2f5eb06235c1f35970e0ac7e8", "filename": "2026-08-21T19-11-20-278Z-custom-code.json", "branch": "main" }