The kvm2 block reuses KVMConfig; validateKVM gains a block-name variant so messages say kvm2. The package skeleton has a non-dialing New, strict providerID parsing, the state table and a 34-row classify test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
192 lines
8.0 KiB
Go
192 lines
8.0 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 optional cloud-init vendor-data placed on every seed next
|
|
// to the Proxy's own user-data. Note that cloud-init drops any top-level
|
|
// key of a vendor-data cloud-config that the user-data also defines.
|
|
// When InstallGuestAgent is on, this document and the agent script are
|
|
// carried as separate parts of a MIME multipart vendor-data.
|
|
VendorData string `json:"vendorData,omitempty"`
|
|
// InstallGuestAgent adds a vendor-data script that installs and starts
|
|
// qemu-guest-agent, which is how the provider learns a VM's address
|
|
// before the host's ARP table has seen traffic from it. Default true.
|
|
// Turn it off for images that already ship the agent, or that cannot
|
|
// reach a package mirror; discovery then depends on ARP alone.
|
|
InstallGuestAgent *bool `json:"installGuestAgent,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"`
|
|
}
|
|
|
|
// Accepted KVMImage.Format and KVMImage.Firmware values.
|
|
const (
|
|
KVMFormatQcow2 = "qcow2"
|
|
KVMFormatRaw = "raw"
|
|
KVMFirmwareUEFI = "uefi"
|
|
KVMFirmwareBIOS = "bios"
|
|
)
|
|
|
|
// IsHTTPURL reports whether s is an absolute http(s) URL with a host, the
|
|
// only kind of image source the kvm provider can fetch.
|
|
func IsHTTPURL(s string) bool {
|
|
u, err := url.Parse(s)
|
|
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
|
}
|
|
|
|
var sha256Hex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
|
|
|
// kvmIdentifier constrains the config names that are pasted into
|
|
// identifiers elsewhere: a host name goes into the providerID
|
|
// hosts/<name>/domains/<vm>, which is split on "/" to parse, and an image
|
|
// key becomes the file name of the cached base volume. A slash in either
|
|
// produces an ID or a path that never resolves again, so the VM leaks with
|
|
// nothing able to address it.
|
|
var kvmIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
|
|
|
func validateKVM(prefix string, c *KVMConfig) error {
|
|
return validateKVMBlock(prefix, "kvm", c)
|
|
}
|
|
|
|
// validateKVMBlock checks a KVMConfig under the given block name, so the
|
|
// kvm and kvm2 provider types share one validator and each reports its own
|
|
// block in messages.
|
|
func validateKVMBlock(prefix, block string, c *KVMConfig) error {
|
|
if c == nil {
|
|
return fmt.Errorf("%s: %s block is required", prefix, block)
|
|
}
|
|
if c.SSHKeyFile == "" {
|
|
return fmt.Errorf("%s: %s.sshKeyFile is required", prefix, block)
|
|
}
|
|
if c.KnownHostsFile == "" && !c.InsecureIgnoreHostKey {
|
|
return fmt.Errorf("%s: %s.knownHostsFile is required unless %s.insecureIgnoreHostKey is true", prefix, block, block)
|
|
}
|
|
if c.DiskSizeGB < 0 {
|
|
return fmt.Errorf("%s: %s.diskSizeGb must not be negative", prefix, block)
|
|
}
|
|
for name, img := range c.Images {
|
|
p := fmt.Sprintf("%s: %s.images[%q]", prefix, block, name)
|
|
if !kvmIdentifier.MatchString(name) {
|
|
return fmt.Errorf("%s: key must be letters, digits, dots, dashes or underscores"+
|
|
" — it becomes the cached volume name base-<key>.<format>", p)
|
|
}
|
|
if !IsHTTPURL(img.URL) {
|
|
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 != KVMFormatQcow2 && img.Format != KVMFormatRaw {
|
|
return fmt.Errorf("%s.format must be %s or %s (got %q)", p, KVMFormatQcow2, KVMFormatRaw, img.Format)
|
|
}
|
|
if img.Firmware != "" && img.Firmware != KVMFirmwareUEFI && img.Firmware != KVMFirmwareBIOS {
|
|
return fmt.Errorf("%s.firmware must be %s or %s (got %q)", p, KVMFirmwareUEFI, KVMFirmwareBIOS, img.Firmware)
|
|
}
|
|
}
|
|
if len(c.MachineTypes) == 0 {
|
|
return fmt.Errorf("%s: %s.machineTypes must define at least one profile", prefix, block)
|
|
}
|
|
for name, mt := range c.MachineTypes {
|
|
if mt.VCPUs <= 0 || mt.MemoryMiB <= 0 {
|
|
return fmt.Errorf("%s: %s.machineTypes[%q] needs vcpus > 0 and memoryMiB > 0", prefix, block, name)
|
|
}
|
|
}
|
|
if len(c.Hosts) == 0 {
|
|
return fmt.Errorf("%s: %s.hosts must list at least one host", prefix, block)
|
|
}
|
|
seen := make(map[string]bool, len(c.Hosts))
|
|
for i, h := range c.Hosts {
|
|
p := fmt.Sprintf("%s: %s.hosts[%d]", prefix, block, i)
|
|
if h.Name == "" {
|
|
return fmt.Errorf("%s: name is required", p)
|
|
}
|
|
if !kvmIdentifier.MatchString(h.Name) {
|
|
return fmt.Errorf("%s: name %q must be letters, digits, dots, dashes or underscores"+
|
|
" — it becomes part of the providerID hosts/<name>/domains/<vm>", p, h.Name)
|
|
}
|
|
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
|
|
}
|