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://:8006. The host must be a name // or address in the node certificate. Required. URL string `json:"url"` // TokenFile holds one line, @!=, 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 }