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//domains/, which is split on "/" to parse, and an image // key becomes part of 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_.-]*$`) // IsKVMIdentifier reports whether s is safe to paste into a providerID or a // volume name. The provider entry's own name reaches a volume name too, and // that name is validated for every provider type at once (only non-empty // and unique), so the kvm2 provider checks its own at construction. func IsKVMIdentifier(s string) bool { return kvmIdentifier.MatchString(s) } 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 part of the cached base volume's name", 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//domains/", 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 }