proxmox: config block, IDs, marks, state mapping, classify, registration
Phase 2 of the proxmox provider plan. Adds the proxmox: config block with its validation, registers the type in the config allow-list and the constructor map, and starts internal/provider/proxmox: the api seam and its data types, providerIDs (vms/<vmid>/<name>) with the UID-derived VMID, the description mark, state mapping, and error classification. Tests read the responses captured from pv1-1 in Phase 0 rather than repeating Proxmox's wording, and every matcher is run against every captured error so none fires on the wrong message. The contract methods are Transient stubs until Phases 5 to 7. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// ProxmoxConfig configures a proxmox provider instance: one Proxmox VE
|
||||
// cluster reached through its REST API with an API token. VMs are full
|
||||
// clones of prepared templates, seeded with a NoCloud ISO uploaded to
|
||||
// ISOStorage. The design is in docs/proxmox-provider-spec.md.
|
||||
type ProxmoxConfig struct {
|
||||
// URL is the API endpoint, https://<host>:8006. The host must be a name
|
||||
// or address in the node certificate. Required.
|
||||
URL string `json:"url"`
|
||||
// TokenFile holds one line, <user>@<realm>!<tokenid>=<secret>, read on
|
||||
// every request. Required.
|
||||
TokenFile string `json:"tokenFile"`
|
||||
// CAFile is a PEM bundle trusted for the API's certificate, typically the
|
||||
// node's /etc/pve/pve-root-ca.pem. Empty means the system roots.
|
||||
CAFile string `json:"caFile,omitempty"`
|
||||
// InsecureSkipTLSVerify disables certificate verification.
|
||||
InsecureSkipTLSVerify bool `json:"insecureSkipTLSVerify,omitempty"`
|
||||
// Nodes are the Proxmox node names placement.zone selects from.
|
||||
// Required, at least one, unique.
|
||||
Nodes []string `json:"nodes"`
|
||||
// ISOStorage is the storage seed ISOs are uploaded to and deleted from;
|
||||
// the token needs Datastore.Allocate on it. Required.
|
||||
ISOStorage string `json:"isoStorage"`
|
||||
// Storage is the target storage of the full clone. Empty clones onto the
|
||||
// template's own disk storage.
|
||||
Storage string `json:"storage,omitempty"`
|
||||
// Bridge replaces the bridge of the cloned net0. Empty keeps it.
|
||||
Bridge string `json:"bridge,omitempty"`
|
||||
// VLANTag sets the VLAN tag of the cloned net0, 1 to 4094. Zero keeps
|
||||
// whatever the template has.
|
||||
VLANTag int `json:"vlanTag,omitempty"`
|
||||
// VMIDRange is where VMIDs are allocated. Default 100000 to 199999.
|
||||
VMIDRange *ProxmoxVMIDRange `json:"vmidRange,omitempty"`
|
||||
// Templates are the clone sources placement.image names. Required, at
|
||||
// least one.
|
||||
Templates map[string]ProxmoxTemplate `json:"templates"`
|
||||
// MachineTypes are the size profiles placement.machineType names.
|
||||
// Required, at least one.
|
||||
MachineTypes map[string]ProxmoxMachineType `json:"machineTypes"`
|
||||
// RequestTimeoutSeconds bounds one API request. Default 30.
|
||||
RequestTimeoutSeconds int `json:"requestTimeoutSeconds,omitempty"`
|
||||
// VendorData is optional cloud-init vendor-data placed on every seed next
|
||||
// to the Proxy's own user-data, as for the kvm providers.
|
||||
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.
|
||||
// Default true. Turn it off for templates that ship the agent.
|
||||
InstallGuestAgent *bool `json:"installGuestAgent,omitempty"`
|
||||
}
|
||||
|
||||
// ProxmoxVMIDRange bounds the VMIDs the provider allocates, both inclusive.
|
||||
type ProxmoxVMIDRange struct {
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
// ProxmoxTemplate is one clone source: a VM converted to a template.
|
||||
type ProxmoxTemplate struct {
|
||||
VMID int `json:"vmid"`
|
||||
}
|
||||
|
||||
// ProxmoxMachineType is a VM size profile. DiskGiB grows the cloned boot
|
||||
// disk when larger than the template's; zero keeps the template's size.
|
||||
type ProxmoxMachineType struct {
|
||||
Cores int32 `json:"cores"`
|
||||
MemoryMiB int64 `json:"memoryMiB"`
|
||||
DiskGiB int64 `json:"diskGiB,omitempty"`
|
||||
}
|
||||
|
||||
// Proxmox's own limits on VMIDs and VLAN tags.
|
||||
const (
|
||||
ProxmoxMinVMID = 100
|
||||
ProxmoxMaxVMID = 999999999
|
||||
proxmoxMaxVLANTag = 4094
|
||||
)
|
||||
|
||||
var (
|
||||
// proxmoxNodeName is a Proxmox node name: a DNS label, as pvecm allows.
|
||||
proxmoxNodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
||||
// proxmoxStorageID is a Proxmox storage identifier.
|
||||
proxmoxStorageID = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_.-]*$`)
|
||||
)
|
||||
|
||||
func validateProxmox(prefix string, c *ProxmoxConfig) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("%s: proxmox block is required", prefix)
|
||||
}
|
||||
if err := validateProxmoxURL(c.URL); err != nil {
|
||||
return fmt.Errorf("%s: proxmox.url %w", prefix, err)
|
||||
}
|
||||
if c.TokenFile == "" {
|
||||
return fmt.Errorf("%s: proxmox.tokenFile is required", prefix)
|
||||
}
|
||||
if len(c.Nodes) == 0 {
|
||||
return fmt.Errorf("%s: proxmox.nodes must list at least one node", prefix)
|
||||
}
|
||||
seen := make(map[string]bool, len(c.Nodes))
|
||||
for i, n := range c.Nodes {
|
||||
if !proxmoxNodeName.MatchString(n) {
|
||||
return fmt.Errorf("%s: proxmox.nodes[%d] %q is not a Proxmox node name (lowercase letters, digits and dashes)", prefix, i, n)
|
||||
}
|
||||
if seen[n] {
|
||||
return fmt.Errorf("%s: proxmox.nodes[%d]: duplicate node %q", prefix, i, n)
|
||||
}
|
||||
seen[n] = true
|
||||
}
|
||||
if c.ISOStorage == "" {
|
||||
return fmt.Errorf("%s: proxmox.isoStorage is required: name a storage with iso content where the token holds Datastore.Allocate", prefix)
|
||||
}
|
||||
if !proxmoxStorageID.MatchString(c.ISOStorage) {
|
||||
return fmt.Errorf("%s: proxmox.isoStorage %q is not a Proxmox storage id", prefix, c.ISOStorage)
|
||||
}
|
||||
if c.Storage != "" && !proxmoxStorageID.MatchString(c.Storage) {
|
||||
return fmt.Errorf("%s: proxmox.storage %q is not a Proxmox storage id", prefix, c.Storage)
|
||||
}
|
||||
if c.VLANTag < 0 || c.VLANTag > proxmoxMaxVLANTag {
|
||||
return fmt.Errorf("%s: proxmox.vlanTag must be between 1 and %d (got %d)", prefix, proxmoxMaxVLANTag, c.VLANTag)
|
||||
}
|
||||
if r := c.VMIDRange; r != nil {
|
||||
if r.Min < ProxmoxMinVMID || r.Max > ProxmoxMaxVMID || r.Min >= r.Max {
|
||||
return fmt.Errorf("%s: proxmox.vmidRange must satisfy %d <= min < max <= %d (got %d to %d)", prefix, ProxmoxMinVMID, ProxmoxMaxVMID, r.Min, r.Max)
|
||||
}
|
||||
}
|
||||
if len(c.Templates) == 0 {
|
||||
return fmt.Errorf("%s: proxmox.templates must define at least one template", prefix)
|
||||
}
|
||||
for key, t := range c.Templates {
|
||||
if key == "" {
|
||||
return fmt.Errorf("%s: proxmox.templates has an empty key", prefix)
|
||||
}
|
||||
if t.VMID < ProxmoxMinVMID {
|
||||
return fmt.Errorf("%s: proxmox.templates[%q].vmid must be at least %d (got %d)", prefix, key, ProxmoxMinVMID, t.VMID)
|
||||
}
|
||||
}
|
||||
if len(c.MachineTypes) == 0 {
|
||||
return fmt.Errorf("%s: proxmox.machineTypes must define at least one profile", prefix)
|
||||
}
|
||||
for key, mt := range c.MachineTypes {
|
||||
if key == "" {
|
||||
return fmt.Errorf("%s: proxmox.machineTypes has an empty key", prefix)
|
||||
}
|
||||
if mt.Cores <= 0 || mt.MemoryMiB <= 0 {
|
||||
return fmt.Errorf("%s: proxmox.machineTypes[%q] needs cores > 0 and memoryMiB > 0", prefix, key)
|
||||
}
|
||||
if mt.DiskGiB < 0 {
|
||||
return fmt.Errorf("%s: proxmox.machineTypes[%q].diskGiB must not be negative", prefix, key)
|
||||
}
|
||||
}
|
||||
if c.RequestTimeoutSeconds < 0 {
|
||||
return fmt.Errorf("%s: proxmox.requestTimeoutSeconds must not be negative", prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProxmoxURL accepts only an https URL with a host and no path, so
|
||||
// the client can append /api2/json itself.
|
||||
func validateProxmoxURL(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("is required")
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("does not parse: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" || u.Host == "" {
|
||||
return fmt.Errorf("must be an https URL with a host (got %q)", s)
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("must have no path, query or fragment; the client appends /api2/json (got %q)", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user