Run 5 on the real host bisected the guest hang: the Debian 13 image hangs after GRUB under SeaBIOS with or without our seed and with either CPU model, and boots under OVMF. The image's legacy boot chain is the problem, not the provider. images.<key>.firmware is "uefi" (default) or "bios". The domain XML emits <os firmware="efi"> with secure boot off, so libvirt selects the OVMF binary and manages the NVRAM file, which the existing nvram undefine flag removes. Direct-URL images default to UEFI too. OVMF joins the hypervisor prerequisites; sample, config table, known gaps, decision log and the execution log record the finding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
147 lines
5.8 KiB
Go
147 lines
5.8 KiB
Go
package provider
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
)
|
|
|
|
// KVMConfig configures a kvm provider instance: one or more libvirt/KVM
|
|
// hypervisors reached over SSH (the libvirt unix socket is forwarded through
|
|
// the SSH connection, so hosts need no libvirtd TCP/TLS listener).
|
|
// placement.zone on a Proxy selects a host by name; with a single host it may
|
|
// be omitted.
|
|
type KVMConfig struct {
|
|
// SSHKeyFile is the path to the private key used for every host. Required.
|
|
SSHKeyFile string `json:"sshKeyFile"`
|
|
// KnownHostsFile is an OpenSSH known_hosts file used to verify host keys.
|
|
// Required unless InsecureIgnoreHostKey is set.
|
|
KnownHostsFile string `json:"knownHostsFile,omitempty"`
|
|
// InsecureIgnoreHostKey skips host-key verification entirely.
|
|
InsecureIgnoreHostKey bool `json:"insecureIgnoreHostKey,omitempty"`
|
|
// DiskSizeGB is the capacity of each VM's overlay disk. Default 10. The
|
|
// guest's cloud-init growpart expands the root filesystem into it.
|
|
DiskSizeGB int64 `json:"diskSizeGb,omitempty"`
|
|
// VendorData is cloud-init vendor-data placed on every seed next to the
|
|
// Proxy's own user-data. Default: installs and starts qemu-guest-agent so
|
|
// the provider can read the VM's IP even before it shows up in ARP.
|
|
VendorData string `json:"vendorData,omitempty"`
|
|
// Images are named base images; placement.image names a key here (or is
|
|
// an http(s) URL directly). The provider downloads each image into a
|
|
// host's storage pool on first use and caches it there.
|
|
Images map[string]KVMImage `json:"images,omitempty"`
|
|
// MachineTypes are named size profiles; placement.machineType names a
|
|
// key here. Required, at least one.
|
|
MachineTypes map[string]KVMMachineType `json:"machineTypes"`
|
|
// Hosts are the hypervisors. Required, at least one, unique names.
|
|
Hosts []KVMHost `json:"hosts"`
|
|
}
|
|
|
|
// KVMImage is one downloadable base image (a distro cloud image with
|
|
// cloud-init preinstalled, e.g. Debian's generic qcow2).
|
|
type KVMImage struct {
|
|
// URL is where to fetch the image from. Required, http or https.
|
|
URL string `json:"url"`
|
|
// SHA256 is the expected hex digest of the file. Optional; when set it is
|
|
// verified after download and a later change triggers a re-download.
|
|
SHA256 string `json:"sha256,omitempty"`
|
|
// Format is the image's disk format, "qcow2" (default) or "raw".
|
|
Format string `json:"format,omitempty"`
|
|
// Firmware is how VMs from this image boot: "uefi" (default; OVMF with
|
|
// secure boot off) or "bios" (SeaBIOS) for images with no EFI system
|
|
// partition. Current Debian, Ubuntu, Fedora and Rocky cloud images all
|
|
// boot UEFI, and Debian 13's BIOS path was found not to boot at all.
|
|
Firmware string `json:"firmware,omitempty"`
|
|
}
|
|
|
|
// KVMMachineType is a VM size profile.
|
|
type KVMMachineType struct {
|
|
VCPUs int32 `json:"vcpus"`
|
|
MemoryMiB int64 `json:"memoryMiB"`
|
|
}
|
|
|
|
// KVMHost is one hypervisor.
|
|
type KVMHost struct {
|
|
// Name is the placement.zone value that selects this host. Required.
|
|
Name string `json:"name"`
|
|
// Address is the SSH endpoint, host:port. Required.
|
|
Address string `json:"address"`
|
|
// User is the SSH user; it must be allowed to use the libvirt socket
|
|
// (typically a member of the libvirt group). Required.
|
|
User string `json:"user"`
|
|
// Socket is the libvirt unix socket path on the host.
|
|
// Default /var/run/libvirt/libvirt-sock.
|
|
Socket string `json:"socket,omitempty"`
|
|
// Pool is the libvirt storage pool for base images, overlay disks and
|
|
// seed ISOs. Default "default".
|
|
Pool string `json:"pool,omitempty"`
|
|
// Bridge is the host bridge the VM's NIC attaches to; it must lead to a
|
|
// network the cluster can reach and that hands out DHCP leases. Required.
|
|
Bridge string `json:"bridge"`
|
|
}
|
|
|
|
var sha256Hex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
|
|
|
func validateKVM(prefix string, c *KVMConfig) error {
|
|
if c == nil {
|
|
return fmt.Errorf("%s: kvm block is required", prefix)
|
|
}
|
|
if c.SSHKeyFile == "" {
|
|
return fmt.Errorf("%s: kvm.sshKeyFile is required", prefix)
|
|
}
|
|
if c.KnownHostsFile == "" && !c.InsecureIgnoreHostKey {
|
|
return fmt.Errorf("%s: kvm.knownHostsFile is required unless kvm.insecureIgnoreHostKey is true", prefix)
|
|
}
|
|
if c.DiskSizeGB < 0 {
|
|
return fmt.Errorf("%s: kvm.diskSizeGb must not be negative", prefix)
|
|
}
|
|
for name, img := range c.Images {
|
|
p := fmt.Sprintf("%s: kvm.images[%q]", prefix, name)
|
|
u, err := url.Parse(img.URL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return fmt.Errorf("%s.url must be an http(s) URL (got %q)", p, img.URL)
|
|
}
|
|
if img.SHA256 != "" && !sha256Hex.MatchString(img.SHA256) {
|
|
return fmt.Errorf("%s.sha256 must be 64 hex characters", p)
|
|
}
|
|
if img.Format != "" && img.Format != "qcow2" && img.Format != "raw" {
|
|
return fmt.Errorf("%s.format must be qcow2 or raw (got %q)", p, img.Format)
|
|
}
|
|
if img.Firmware != "" && img.Firmware != "uefi" && img.Firmware != "bios" {
|
|
return fmt.Errorf("%s.firmware must be uefi or bios (got %q)", p, img.Firmware)
|
|
}
|
|
}
|
|
if len(c.MachineTypes) == 0 {
|
|
return fmt.Errorf("%s: kvm.machineTypes must define at least one profile", prefix)
|
|
}
|
|
for name, mt := range c.MachineTypes {
|
|
if mt.VCPUs <= 0 || mt.MemoryMiB <= 0 {
|
|
return fmt.Errorf("%s: kvm.machineTypes[%q] needs vcpus > 0 and memoryMiB > 0", prefix, name)
|
|
}
|
|
}
|
|
if len(c.Hosts) == 0 {
|
|
return fmt.Errorf("%s: kvm.hosts must list at least one host", prefix)
|
|
}
|
|
seen := make(map[string]bool, len(c.Hosts))
|
|
for i, h := range c.Hosts {
|
|
p := fmt.Sprintf("%s: kvm.hosts[%d]", prefix, i)
|
|
if h.Name == "" {
|
|
return fmt.Errorf("%s: name is required", p)
|
|
}
|
|
if seen[h.Name] {
|
|
return fmt.Errorf("%s: duplicate host name %q", p, h.Name)
|
|
}
|
|
seen[h.Name] = true
|
|
if h.Address == "" {
|
|
return fmt.Errorf("%s %q: address is required", p, h.Name)
|
|
}
|
|
if h.User == "" {
|
|
return fmt.Errorf("%s %q: user is required", p, h.Name)
|
|
}
|
|
if h.Bridge == "" {
|
|
return fmt.Errorf("%s %q: bridge is required", p, h.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|