pull/7391/head
Calle Pettersson 7 years ago committed by Megan Marsh
parent 2f754c38f8
commit 65f38978f8

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

@ -0,0 +1,597 @@
package proxmox
// inspired by https://github.com/Telmate/vagrant-proxmox/blob/master/lib/vagrant-proxmox/proxmox/connection.rb
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
// TaskTimeout - default async task call timeout in seconds
const TaskTimeout = 300
// TaskStatusCheckInterval - time between async checks in seconds
const TaskStatusCheckInterval = 2
const exitStatusSuccess = "OK"
// Client - URL, user and password to specifc Proxmox node
type Client struct {
session *Session
ApiUrl string
Username string
Password string
}
// VmRef - virtual machine ref parts
// map[type:qemu node:proxmox1-xx id:qemu/132 diskread:5.57424738e+08 disk:0 netin:5.9297450593e+10 mem:3.3235968e+09 uptime:1.4567097e+07 vmid:132 template:0 maxcpu:2 netout:6.053310416e+09 maxdisk:3.4359738368e+10 maxmem:8.592031744e+09 diskwrite:1.49663619584e+12 status:running cpu:0.00386980694947209 name:appt-app1-dev.xxx.xx]
type VmRef struct {
vmId int
node string
vmType string
}
func (vmr *VmRef) SetNode(node string) {
vmr.node = node
return
}
func (vmr *VmRef) SetVmType(vmType string) {
vmr.vmType = vmType
return
}
func (vmr *VmRef) VmId() int {
return vmr.vmId
}
func (vmr *VmRef) Node() string {
return vmr.node
}
func NewVmRef(vmId int) (vmr *VmRef) {
vmr = &VmRef{vmId: vmId, node: "", vmType: ""}
return
}
func NewClient(apiUrl string, hclient *http.Client, tls *tls.Config) (client *Client, err error) {
var sess *Session
sess, err = NewSession(apiUrl, hclient, tls)
if err == nil {
client = &Client{session: sess, ApiUrl: apiUrl}
}
return client, err
}
func (c *Client) Login(username string, password string) (err error) {
c.Username = username
c.Password = password
return c.session.Login(username, password)
}
func (c *Client) GetJsonRetryable(url string, data *map[string]interface{}, tries int) error {
var statErr error
for ii := 0; ii < tries; ii++ {
_, statErr = c.session.GetJSON(url, nil, nil, data)
if statErr == nil {
return nil
}
// if statErr != io.ErrUnexpectedEOF { // don't give up on ErrUnexpectedEOF
// return statErr
// }
time.Sleep(5 * time.Second)
}
return statErr
}
func (c *Client) GetNodeList() (list map[string]interface{}, err error) {
err = c.GetJsonRetryable("/nodes", &list, 3)
return
}
func (c *Client) GetVmList() (list map[string]interface{}, err error) {
err = c.GetJsonRetryable("/cluster/resources?type=vm", &list, 3)
return
}
func (c *Client) CheckVmRef(vmr *VmRef) (err error) {
if vmr.node == "" || vmr.vmType == "" {
_, err = c.GetVmInfo(vmr)
}
return
}
func (c *Client) GetVmInfo(vmr *VmRef) (vmInfo map[string]interface{}, err error) {
resp, err := c.GetVmList()
vms := resp["data"].([]interface{})
for vmii := range vms {
vm := vms[vmii].(map[string]interface{})
if int(vm["vmid"].(float64)) == vmr.vmId {
vmInfo = vm
vmr.node = vmInfo["node"].(string)
vmr.vmType = vmInfo["type"].(string)
return
}
}
return nil, errors.New(fmt.Sprintf("Vm '%d' not found", vmr.vmId))
}
func (c *Client) GetVmRefByName(vmName string) (vmr *VmRef, err error) {
resp, err := c.GetVmList()
vms := resp["data"].([]interface{})
for vmii := range vms {
vm := vms[vmii].(map[string]interface{})
if vm["name"] != nil && vm["name"].(string) == vmName {
vmr = NewVmRef(int(vm["vmid"].(float64)))
vmr.node = vm["node"].(string)
vmr.vmType = vm["type"].(string)
return
}
}
return nil, errors.New(fmt.Sprintf("Vm '%s' not found", vmName))
}
func (c *Client) GetVmState(vmr *VmRef) (vmState map[string]interface{}, err error) {
err = c.CheckVmRef(vmr)
if err != nil {
return nil, err
}
var data map[string]interface{}
url := fmt.Sprintf("/nodes/%s/%s/%d/status/current", vmr.node, vmr.vmType, vmr.vmId)
err = c.GetJsonRetryable(url, &data, 3)
if err != nil {
return nil, err
}
if data["data"] == nil {
return nil, errors.New("Vm STATE not readable")
}
vmState = data["data"].(map[string]interface{})
return
}
func (c *Client) GetVmConfig(vmr *VmRef) (vmConfig map[string]interface{}, err error) {
err = c.CheckVmRef(vmr)
if err != nil {
return nil, err
}
var data map[string]interface{}
url := fmt.Sprintf("/nodes/%s/%s/%d/config", vmr.node, vmr.vmType, vmr.vmId)
err = c.GetJsonRetryable(url, &data, 3)
if err != nil {
return nil, err
}
if data["data"] == nil {
return nil, errors.New("Vm CONFIG not readable")
}
vmConfig = data["data"].(map[string]interface{})
return
}
func (c *Client) GetVmSpiceProxy(vmr *VmRef) (vmSpiceProxy map[string]interface{}, err error) {
err = c.CheckVmRef(vmr)
if err != nil {
return nil, err
}
var data map[string]interface{}
url := fmt.Sprintf("/nodes/%s/%s/%d/spiceproxy", vmr.node, vmr.vmType, vmr.vmId)
_, err = c.session.PostJSON(url, nil, nil, nil, &data)
if err != nil {
return nil, err
}
if data["data"] == nil {
return nil, errors.New("Vm SpiceProxy not readable")
}
vmSpiceProxy = data["data"].(map[string]interface{})
return
}
type AgentNetworkInterface struct {
MACAddress string
IPAddresses []net.IP
Name string
Statistics map[string]int64
}
func (a *AgentNetworkInterface) UnmarshalJSON(b []byte) error {
var intermediate struct {
HardwareAddress string `json:"hardware-address"`
IPAddresses []struct {
IPAddress string `json:"ip-address"`
IPAddressType string `json:"ip-address-type"`
Prefix int `json:"prefix"`
} `json:"ip-addresses"`
Name string `json:"name"`
Statistics map[string]int64 `json:"statistics"`
}
err := json.Unmarshal(b, &intermediate)
if err != nil {
return err
}
a.IPAddresses = make([]net.IP, len(intermediate.IPAddresses))
for idx, ip := range intermediate.IPAddresses {
a.IPAddresses[idx] = net.ParseIP(ip.IPAddress)
if a.IPAddresses[idx] == nil {
return fmt.Errorf("Could not parse %s as IP", ip.IPAddress)
}
}
a.MACAddress = intermediate.HardwareAddress
a.Name = intermediate.Name
a.Statistics = intermediate.Statistics
return nil
}
func (c *Client) GetVmAgentNetworkInterfaces(vmr *VmRef) ([]AgentNetworkInterface, error) {
var ifs []AgentNetworkInterface
err := c.doAgentGet(vmr, "network-get-interfaces", &ifs)
return ifs, err
}
func (c *Client) doAgentGet(vmr *VmRef, command string, output interface{}) error {
err := c.CheckVmRef(vmr)
if err != nil {
return err
}
url := fmt.Sprintf("/nodes/%s/%s/%d/agent/%s", vmr.node, vmr.vmType, vmr.vmId, command)
resp, err := c.session.Get(url, nil, nil)
if err != nil {
return err
}
return TypedResponse(resp, output)
}
func (c *Client) CreateTemplate(vmr *VmRef) error {
err := c.CheckVmRef(vmr)
if err != nil {
return err
}
url := fmt.Sprintf("/nodes/%s/%s/%d/template", vmr.node, vmr.vmType, vmr.vmId)
_, err = c.session.Post(url, nil, nil, nil)
if err != nil {
return err
}
return nil
}
func (c *Client) MonitorCmd(vmr *VmRef, command string) (monitorRes map[string]interface{}, err error) {
err = c.CheckVmRef(vmr)
if err != nil {
return nil, err
}
reqbody := ParamsToBody(map[string]interface{}{"command": command})
url := fmt.Sprintf("/nodes/%s/%s/%d/monitor", vmr.node, vmr.vmType, vmr.vmId)
resp, err := c.session.Post(url, nil, nil, &reqbody)
monitorRes, err = ResponseJSON(resp)
return
}
// WaitForCompletion - poll the API for task completion
func (c *Client) WaitForCompletion(taskResponse map[string]interface{}) (waitExitStatus string, err error) {
if taskResponse["errors"] != nil {
errJSON, _ := json.MarshalIndent(taskResponse["errors"], "", " ")
return string(errJSON), errors.New("Error reponse")
}
if taskResponse["data"] == nil {
return "", nil
}
waited := 0
taskUpid := taskResponse["data"].(string)
for waited < TaskTimeout {
exitStatus, statErr := c.GetTaskExitstatus(taskUpid)
if statErr != nil {
if statErr != io.ErrUnexpectedEOF { // don't give up on ErrUnexpectedEOF
return "", statErr
}
}
if exitStatus != nil {
waitExitStatus = exitStatus.(string)
return
}
time.Sleep(TaskStatusCheckInterval * time.Second)
waited = waited + TaskStatusCheckInterval
}
return "", errors.New("Wait timeout for:" + taskUpid)
}
var rxTaskNode = regexp.MustCompile("UPID:(.*?):")
func (c *Client) GetTaskExitstatus(taskUpid string) (exitStatus interface{}, err error) {
node := rxTaskNode.FindStringSubmatch(taskUpid)[1]
url := fmt.Sprintf("/nodes/%s/tasks/%s/status", node, taskUpid)
var data map[string]interface{}
_, err = c.session.GetJSON(url, nil, nil, &data)
if err == nil {
exitStatus = data["data"].(map[string]interface{})["exitstatus"]
}
if exitStatus != nil && exitStatus != exitStatusSuccess {
err = errors.New(exitStatus.(string))
}
return
}
func (c *Client) StatusChangeVm(vmr *VmRef, setStatus string) (exitStatus string, err error) {
err = c.CheckVmRef(vmr)
if err != nil {
return "", err
}
url := fmt.Sprintf("/nodes/%s/%s/%d/status/%s", vmr.node, vmr.vmType, vmr.vmId, setStatus)
var taskResponse map[string]interface{}
for i := 0; i < 3; i++ {
_, err = c.session.PostJSON(url, nil, nil, nil, &taskResponse)
exitStatus, err = c.WaitForCompletion(taskResponse)
if exitStatus == "" {
time.Sleep(TaskStatusCheckInterval * time.Second)
} else {
return
}
}
return
}
func (c *Client) StartVm(vmr *VmRef) (exitStatus string, err error) {
return c.StatusChangeVm(vmr, "start")
}
func (c *Client) StopVm(vmr *VmRef) (exitStatus string, err error) {
return c.StatusChangeVm(vmr, "stop")
}
func (c *Client) ShutdownVm(vmr *VmRef) (exitStatus string, err error) {
return c.StatusChangeVm(vmr, "shutdown")
}
func (c *Client) ResetVm(vmr *VmRef) (exitStatus string, err error) {
return c.StatusChangeVm(vmr, "reset")
}
func (c *Client) SuspendVm(vmr *VmRef) (exitStatus string, err error) {
return c.StatusChangeVm(vmr, "suspend")
}
func (c *Client) ResumeVm(vmr *VmRef) (exitStatus string, err error) {
return c.StatusChangeVm(vmr, "resume")
}
func (c *Client) DeleteVm(vmr *VmRef) (exitStatus string, err error) {
err = c.CheckVmRef(vmr)
if err != nil {
return "", err
}
url := fmt.Sprintf("/nodes/%s/%s/%d", vmr.node, vmr.vmType, vmr.vmId)
var taskResponse map[string]interface{}
_, err = c.session.RequestJSON("DELETE", url, nil, nil, nil, &taskResponse)
exitStatus, err = c.WaitForCompletion(taskResponse)
return
}
func (c *Client) CreateQemuVm(node string, vmParams map[string]interface{}) (exitStatus string, err error) {
// Create VM disks first to ensure disks names.
createdDisks, createdDisksErr := c.createVMDisks(node, vmParams)
if createdDisksErr != nil {
return "", createdDisksErr
}
// Then create the VM itself.
reqbody := ParamsToBody(vmParams)
url := fmt.Sprintf("/nodes/%s/qemu", node)
var resp *http.Response
resp, err = c.session.Post(url, nil, nil, &reqbody)
defer resp.Body.Close()
if err != nil {
b, _ := ioutil.ReadAll(resp.Body)
exitStatus = string(b)
return
}
taskResponse, err := ResponseJSON(resp)
if err != nil {
return
}
exitStatus, err = c.WaitForCompletion(taskResponse)
// Delete VM disks if the VM didn't create.
if exitStatus != "OK" {
deleteDisksErr := c.DeleteVMDisks(node, createdDisks)
if deleteDisksErr != nil {
return "", deleteDisksErr
}
}
return
}
func (c *Client) CloneQemuVm(vmr *VmRef, vmParams map[string]interface{}) (exitStatus string, err error) {
reqbody := ParamsToBody(vmParams)
url := fmt.Sprintf("/nodes/%s/qemu/%d/clone", vmr.node, vmr.vmId)
resp, err := c.session.Post(url, nil, nil, &reqbody)
if err == nil {
taskResponse, err := ResponseJSON(resp)
if err != nil {
return "", err
}
exitStatus, err = c.WaitForCompletion(taskResponse)
}
return
}
func (c *Client) RollbackQemuVm(vmr *VmRef, snapshot string) (exitStatus string, err error) {
err = c.CheckVmRef(vmr)
if err != nil {
return "", err
}
url := fmt.Sprintf("/nodes/%s/%s/%d/snapshot/%s/rollback", vmr.node, vmr.vmType, vmr.vmId, snapshot)
var taskResponse map[string]interface{}
_, err = c.session.PostJSON(url, nil, nil, nil, &taskResponse)
exitStatus, err = c.WaitForCompletion(taskResponse)
return
}
// SetVmConfig - send config options
func (c *Client) SetVmConfig(vmr *VmRef, vmParams map[string]interface{}) (exitStatus interface{}, err error) {
reqbody := ParamsToBody(vmParams)
url := fmt.Sprintf("/nodes/%s/%s/%d/config", vmr.node, vmr.vmType, vmr.vmId)
resp, err := c.session.Post(url, nil, nil, &reqbody)
if err == nil {
taskResponse, err := ResponseJSON(resp)
if err != nil {
return nil, err
}
exitStatus, err = c.WaitForCompletion(taskResponse)
}
return
}
func (c *Client) ResizeQemuDisk(vmr *VmRef, disk string, moreSizeGB int) (exitStatus interface{}, err error) {
// PUT
//disk:virtio0
//size:+2G
if disk == "" {
disk = "virtio0"
}
size := fmt.Sprintf("+%dG", moreSizeGB)
reqbody := ParamsToBody(map[string]interface{}{"disk": disk, "size": size})
url := fmt.Sprintf("/nodes/%s/%s/%d/resize", vmr.node, vmr.vmType, vmr.vmId)
resp, err := c.session.Put(url, nil, nil, &reqbody)
if err == nil {
taskResponse, err := ResponseJSON(resp)
if err != nil {
return nil, err
}
exitStatus, err = c.WaitForCompletion(taskResponse)
}
return
}
// GetNextID - Get next free VMID
func (c *Client) GetNextID(currentID int) (nextID int, err error) {
var data map[string]interface{}
var url string
if currentID >= 100 {
url = fmt.Sprintf("/cluster/nextid?vmid=%d", currentID)
} else {
url = "/cluster/nextid"
}
_, err = c.session.GetJSON(url, nil, nil, &data)
if err == nil {
if data["errors"] != nil {
if currentID >= 100 {
return c.GetNextID(currentID + 1)
} else {
return -1, errors.New("error using /cluster/nextid")
}
}
nextID, err = strconv.Atoi(data["data"].(string))
}
return
}
// CreateVMDisk - Create single disk for VM on host node.
func (c *Client) CreateVMDisk(
nodeName string,
storageName string,
fullDiskName string,
diskParams map[string]interface{},
) error {
reqbody := ParamsToBody(diskParams)
url := fmt.Sprintf("/nodes/%s/storage/%s/content", nodeName, storageName)
resp, err := c.session.Post(url, nil, nil, &reqbody)
if err == nil {
taskResponse, err := ResponseJSON(resp)
if err != nil {
return err
}
if diskName, containsData := taskResponse["data"]; !containsData || diskName != fullDiskName {
return errors.New(fmt.Sprintf("Cannot create VM disk %s", fullDiskName))
}
} else {
return err
}
return nil
}
// createVMDisks - Make disks parameters and create all VM disks on host node.
func (c *Client) createVMDisks(
node string,
vmParams map[string]interface{},
) (disks []string, err error) {
var createdDisks []string
vmID := vmParams["vmid"].(int)
for deviceName, deviceConf := range vmParams {
rxStorageModels := `(ide|sata|scsi|virtio)\d+`
if matched, _ := regexp.MatchString(rxStorageModels, deviceName); matched {
deviceConfMap := ParseConf(deviceConf.(string), ",", "=")
// This if condition to differentiate between `disk` and `cdrom`.
if media, containsFile := deviceConfMap["media"]; containsFile && media == "disk" {
fullDiskName := deviceConfMap["file"].(string)
storageName, volumeName := getStorageAndVolumeName(fullDiskName, ":")
diskParams := map[string]interface{}{
"vmid": vmID,
"filename": volumeName,
"size": deviceConfMap["size"],
}
err := c.CreateVMDisk(node, storageName, fullDiskName, diskParams)
if err != nil {
return createdDisks, err
} else {
createdDisks = append(createdDisks, fullDiskName)
}
}
}
}
return createdDisks, nil
}
// DeleteVMDisks - Delete VM disks from host node.
// By default the VM disks are deteled when the VM is deleted,
// so mainly this is used to delete the disks in case VM creation didn't complete.
func (c *Client) DeleteVMDisks(
node string,
disks []string,
) error {
for _, fullDiskName := range disks {
storageName, volumeName := getStorageAndVolumeName(fullDiskName, ":")
url := fmt.Sprintf("/nodes/%s/storage/%s/content/%s", node, storageName, volumeName)
_, err := c.session.Post(url, nil, nil, nil)
if err != nil {
return err
}
}
return nil
}
// getStorageAndVolumeName - Extract disk storage and disk volume, since disk name is saved
// in Proxmox with its storage.
func getStorageAndVolumeName(
fullDiskName string,
separator string,
) (storageName string, diskName string) {
storageAndVolumeName := strings.Split(fullDiskName, separator)
storageName, volumeName := storageAndVolumeName[0], storageAndVolumeName[1]
// when disk type is dir, volumeName is `file=local:100/vm-100-disk-0.raw`
re := regexp.MustCompile(`\d+/(?P<filename>\S+.\S+)`)
match := re.FindStringSubmatch(volumeName)
if len(match) == 2 {
volumeName = match[1]
}
return storageName, volumeName
}

@ -0,0 +1,706 @@
package proxmox
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
type (
QemuDevices map[int]map[string]interface{}
QemuDevice map[string]interface{}
QemuDeviceParam []string
)
// ConfigQemu - Proxmox API QEMU options
type ConfigQemu struct {
Name string `json:"name"`
Description string `json:"desc"`
Onboot bool `json:"onboot"`
Agent string `json:"agent"`
Memory int `json:"memory"`
QemuOs string `json:"os"`
QemuCores int `json:"cores"`
QemuSockets int `json:"sockets"`
QemuIso string `json:"iso"`
FullClone *int `json:"fullclone"`
QemuDisks QemuDevices `json:"disk"`
QemuNetworks QemuDevices `json:"network"`
// Deprecated single disk.
DiskSize float64 `json:"diskGB"`
Storage string `json:"storage"`
StorageType string `json:"storageType"` // virtio|scsi (cloud-init defaults to scsi)
// Deprecated single nic.
QemuNicModel string `json:"nic"`
QemuBrige string `json:"bridge"`
QemuVlanTag int `json:"vlan"`
QemuMacAddr string `json:"mac"`
// cloud-init options
CIuser string `json:"ciuser"`
CIpassword string `json:"cipassword"`
Searchdomain string `json:"searchdomain"`
Nameserver string `json:"nameserver"`
Sshkeys string `json:"sshkeys"`
// arrays are hard, support 2 interfaces for now
Ipconfig0 string `json:"ipconfig0"`
Ipconfig1 string `json:"ipconfig1"`
}
// CreateVm - Tell Proxmox API to make the VM
func (config ConfigQemu) CreateVm(vmr *VmRef, client *Client) (err error) {
if config.HasCloudInit() {
return errors.New("Cloud-init parameters only supported on clones or updates")
}
vmr.SetVmType("qemu")
params := map[string]interface{}{
"vmid": vmr.vmId,
"name": config.Name,
"onboot": config.Onboot,
"agent": config.Agent,
"ide2": config.QemuIso + ",media=cdrom",
"ostype": config.QemuOs,
"sockets": config.QemuSockets,
"cores": config.QemuCores,
"cpu": "host",
"memory": config.Memory,
"description": config.Description,
}
// Create disks config.
config.CreateQemuDisksParams(vmr.vmId, params, false)
// Create networks config.
config.CreateQemuNetworksParams(vmr.vmId, params)
exitStatus, err := client.CreateQemuVm(vmr.node, params)
if err != nil {
return fmt.Errorf("Error creating VM: %v, error status: %s (params: %v)", err, exitStatus, params)
}
return
}
// HasCloudInit - are there cloud-init options?
func (config ConfigQemu) HasCloudInit() bool {
return config.CIuser != "" ||
config.CIpassword != "" ||
config.Searchdomain != "" ||
config.Nameserver != "" ||
config.Sshkeys != "" ||
config.Ipconfig0 != "" ||
config.Ipconfig1 != ""
}
/*
CloneVm
Example: Request
nodes/proxmox1-xx/qemu/1012/clone
newid:145
name:tf-clone1
target:proxmox1-xx
full:1
storage:xxx
*/
func (config ConfigQemu) CloneVm(sourceVmr *VmRef, vmr *VmRef, client *Client) (err error) {
vmr.SetVmType("qemu")
fullclone := "1"
if config.FullClone != nil {
fullclone = strconv.Itoa(*config.FullClone)
}
storage := config.Storage
if disk0Storage, ok := config.QemuDisks[0]["storage"].(string); ok && len(disk0Storage) > 0 {
storage = disk0Storage
}
params := map[string]interface{}{
"newid": vmr.vmId,
"target": vmr.node,
"name": config.Name,
"storage": storage,
"full": fullclone,
}
_, err = client.CloneQemuVm(sourceVmr, params)
if err != nil {
return
}
return config.UpdateConfig(vmr, client)
}
func (config ConfigQemu) UpdateConfig(vmr *VmRef, client *Client) (err error) {
configParams := map[string]interface{}{
"name": config.Name,
"description": config.Description,
"onboot": config.Onboot,
"agent": config.Agent,
"sockets": config.QemuSockets,
"cores": config.QemuCores,
"memory": config.Memory,
}
// Create disks config.
config.CreateQemuDisksParams(vmr.vmId, configParams, true)
// Create networks config.
config.CreateQemuNetworksParams(vmr.vmId, configParams)
// cloud-init options
if config.CIuser != "" {
configParams["ciuser"] = config.CIuser
}
if config.CIpassword != "" {
configParams["cipassword"] = config.CIpassword
}
if config.Searchdomain != "" {
configParams["searchdomain"] = config.Searchdomain
}
if config.Nameserver != "" {
configParams["nameserver"] = config.Nameserver
}
if config.Sshkeys != "" {
sshkeyEnc := url.PathEscape(config.Sshkeys + "\n")
sshkeyEnc = strings.Replace(sshkeyEnc, "+", "%2B", -1)
sshkeyEnc = strings.Replace(sshkeyEnc, "@", "%40", -1)
sshkeyEnc = strings.Replace(sshkeyEnc, "=", "%3D", -1)
configParams["sshkeys"] = sshkeyEnc
}
if config.Ipconfig0 != "" {
configParams["ipconfig0"] = config.Ipconfig0
}
if config.Ipconfig1 != "" {
configParams["ipconfig1"] = config.Ipconfig1
}
_, err = client.SetVmConfig(vmr, configParams)
return err
}
func NewConfigQemuFromJson(io io.Reader) (config *ConfigQemu, err error) {
config = &ConfigQemu{QemuVlanTag: -1}
err = json.NewDecoder(io).Decode(config)
if err != nil {
log.Fatal(err)
return nil, err
}
log.Println(config)
return
}
var (
rxIso = regexp.MustCompile(`(.*?),media`)
rxDeviceID = regexp.MustCompile(`\d+`)
rxDiskName = regexp.MustCompile(`(virtio|scsi)\d+`)
rxDiskType = regexp.MustCompile(`\D+`)
rxNicName = regexp.MustCompile(`net\d+`)
)
func NewConfigQemuFromApi(vmr *VmRef, client *Client) (config *ConfigQemu, err error) {
var vmConfig map[string]interface{}
for ii := 0; ii < 3; ii++ {
vmConfig, err = client.GetVmConfig(vmr)
if err != nil {
log.Fatal(err)
return nil, err
}
// this can happen:
// {"data":{"lock":"clone","digest":"eb54fb9d9f120ba0c3bdf694f73b10002c375c38","description":" qmclone temporary file\n"}})
if vmConfig["lock"] == nil {
break
} else {
time.Sleep(8 * time.Second)
}
}
if vmConfig["lock"] != nil {
return nil, errors.New("vm locked, could not obtain config")
}
// vmConfig Sample: map[ cpu:host
// net0:virtio=62:DF:XX:XX:XX:XX,bridge=vmbr0
// ide2:local:iso/xxx-xx.iso,media=cdrom memory:2048
// smbios1:uuid=8b3bf833-aad8-4545-xxx-xxxxxxx digest:aa6ce5xxxxx1b9ce33e4aaeff564d4 sockets:1
// name:terraform-ubuntu1404-template bootdisk:virtio0
// virtio0:ProxmoxxxxISCSI:vm-1014-disk-2,size=4G
// description:Base image
// cores:2 ostype:l26
name := ""
if _, isSet := vmConfig["name"]; isSet {
name = vmConfig["name"].(string)
}
description := ""
if _, isSet := vmConfig["description"]; isSet {
description = vmConfig["description"].(string)
}
onboot := true
if _, isSet := vmConfig["onboot"]; isSet {
onboot = Itob(int(vmConfig["onboot"].(float64)))
}
agent := "1"
if _, isSet := vmConfig["agent"]; isSet {
agent = vmConfig["agent"].(string)
}
ostype := "other"
if _, isSet := vmConfig["ostype"]; isSet {
ostype = vmConfig["ostype"].(string)
}
memory := 0.0
if _, isSet := vmConfig["memory"]; isSet {
memory = vmConfig["memory"].(float64)
}
cores := 1.0
if _, isSet := vmConfig["cores"]; isSet {
cores = vmConfig["cores"].(float64)
}
sockets := 1.0
if _, isSet := vmConfig["sockets"]; isSet {
sockets = vmConfig["sockets"].(float64)
}
config = &ConfigQemu{
Name: name,
Description: strings.TrimSpace(description),
Onboot: onboot,
Agent: agent,
QemuOs: ostype,
Memory: int(memory),
QemuCores: int(cores),
QemuSockets: int(sockets),
QemuVlanTag: -1,
QemuDisks: QemuDevices{},
QemuNetworks: QemuDevices{},
}
if vmConfig["ide2"] != nil {
isoMatch := rxIso.FindStringSubmatch(vmConfig["ide2"].(string))
config.QemuIso = isoMatch[1]
}
if _, isSet := vmConfig["ciuser"]; isSet {
config.CIuser = vmConfig["ciuser"].(string)
}
if _, isSet := vmConfig["cipassword"]; isSet {
config.CIpassword = vmConfig["cipassword"].(string)
}
if _, isSet := vmConfig["searchdomain"]; isSet {
config.Searchdomain = vmConfig["searchdomain"].(string)
}
if _, isSet := vmConfig["sshkeys"]; isSet {
config.Sshkeys, _ = url.PathUnescape(vmConfig["sshkeys"].(string))
}
if _, isSet := vmConfig["ipconfig0"]; isSet {
config.Ipconfig0 = vmConfig["ipconfig0"].(string)
}
if _, isSet := vmConfig["ipconfig1"]; isSet {
config.Ipconfig1 = vmConfig["ipconfig1"].(string)
}
// Add disks.
diskNames := []string{}
for k, _ := range vmConfig {
if diskName := rxDiskName.FindStringSubmatch(k); len(diskName) > 0 {
diskNames = append(diskNames, diskName[0])
}
}
for _, diskName := range diskNames {
diskConfStr := vmConfig[diskName]
diskConfList := strings.Split(diskConfStr.(string), ",")
//
id := rxDeviceID.FindStringSubmatch(diskName)
diskID, _ := strconv.Atoi(id[0])
diskType := rxDiskType.FindStringSubmatch(diskName)[0]
storageName, fileName := ParseSubConf(diskConfList[0], ":")
//
diskConfMap := QemuDevice{
"type": diskType,
"storage": storageName,
"file": fileName,
}
// Add rest of device config.
diskConfMap.readDeviceConfig(diskConfList[1:])
// And device config to disks map.
if len(diskConfMap) > 0 {
config.QemuDisks[diskID] = diskConfMap
}
}
// Add networks.
nicNameRe := regexp.MustCompile(`net\d+`)
nicNames := []string{}
for k, _ := range vmConfig {
if nicName := nicNameRe.FindStringSubmatch(k); len(nicName) > 0 {
nicNames = append(nicNames, nicName[0])
}
}
for _, nicName := range nicNames {
nicConfStr := vmConfig[nicName]
nicConfList := strings.Split(nicConfStr.(string), ",")
//
id := rxDeviceID.FindStringSubmatch(nicName)
nicID, _ := strconv.Atoi(id[0])
model, macaddr := ParseSubConf(nicConfList[0], "=")
// Add model and MAC address.
nicConfMap := QemuDevice{
"model": model,
"macaddr": macaddr,
}
// Add rest of device config.
nicConfMap.readDeviceConfig(nicConfList[1:])
// And device config to networks.
if len(nicConfMap) > 0 {
config.QemuNetworks[nicID] = nicConfMap
}
}
return
}
// Useful waiting for ISO install to complete
func WaitForShutdown(vmr *VmRef, client *Client) (err error) {
for ii := 0; ii < 100; ii++ {
vmState, err := client.GetVmState(vmr)
if err != nil {
log.Print("Wait error:")
log.Println(err)
} else if vmState["status"] == "stopped" {
return nil
}
time.Sleep(5 * time.Second)
}
return errors.New("Not shutdown within wait time")
}
// This is because proxmox create/config API won't let us make usernet devices
func SshForwardUsernet(vmr *VmRef, client *Client) (sshPort string, err error) {
vmState, err := client.GetVmState(vmr)
if err != nil {
return "", err
}
if vmState["status"] == "stopped" {
return "", errors.New("VM must be running first")
}
sshPort = strconv.Itoa(vmr.VmId() + 22000)
_, err = client.MonitorCmd(vmr, "netdev_add user,id=net1,hostfwd=tcp::"+sshPort+"-:22")
if err != nil {
return "", err
}
_, err = client.MonitorCmd(vmr, "device_add virtio-net-pci,id=net1,netdev=net1,addr=0x13")
if err != nil {
return "", err
}
return
}
// device_del net1
// netdev_del net1
func RemoveSshForwardUsernet(vmr *VmRef, client *Client) (err error) {
vmState, err := client.GetVmState(vmr)
if err != nil {
return err
}
if vmState["status"] == "stopped" {
return errors.New("VM must be running first")
}
_, err = client.MonitorCmd(vmr, "device_del net1")
if err != nil {
return err
}
_, err = client.MonitorCmd(vmr, "netdev_del net1")
if err != nil {
return err
}
return nil
}
func MaxVmId(client *Client) (max int, err error) {
resp, err := client.GetVmList()
vms := resp["data"].([]interface{})
max = 0
for vmii := range vms {
vm := vms[vmii].(map[string]interface{})
vmid := int(vm["vmid"].(float64))
if vmid > max {
max = vmid
}
}
return
}
func SendKeysString(vmr *VmRef, client *Client, keys string) (err error) {
vmState, err := client.GetVmState(vmr)
if err != nil {
return err
}
if vmState["status"] == "stopped" {
return errors.New("VM must be running first")
}
for _, r := range keys {
c := string(r)
lower := strings.ToLower(c)
if c != lower {
c = "shift-" + lower
} else {
switch c {
case "!":
c = "shift-1"
case "@":
c = "shift-2"
case "#":
c = "shift-3"
case "$":
c = "shift-4"
case "%%":
c = "shift-5"
case "^":
c = "shift-6"
case "&":
c = "shift-7"
case "*":
c = "shift-8"
case "(":
c = "shift-9"
case ")":
c = "shift-0"
case "_":
c = "shift-minus"
case "+":
c = "shift-equal"
case " ":
c = "spc"
case "/":
c = "slash"
case "\\":
c = "backslash"
case ",":
c = "comma"
case "-":
c = "minus"
case "=":
c = "equal"
case ".":
c = "dot"
case "?":
c = "shift-slash"
}
}
_, err = client.MonitorCmd(vmr, "sendkey "+c)
if err != nil {
return err
}
time.Sleep(100)
}
return nil
}
// Create parameters for each Nic device.
func (c ConfigQemu) CreateQemuNetworksParams(vmID int, params map[string]interface{}) error {
// For backward compatibility.
if len(c.QemuNetworks) == 0 && len(c.QemuNicModel) > 0 {
deprecatedStyleMap := QemuDevice{
"model": c.QemuNicModel,
"bridge": c.QemuBrige,
"macaddr": c.QemuMacAddr,
}
if c.QemuVlanTag > 0 {
deprecatedStyleMap["tag"] = strconv.Itoa(c.QemuVlanTag)
}
c.QemuNetworks[0] = deprecatedStyleMap
}
// For new style with multi net device.
for nicID, nicConfMap := range c.QemuNetworks {
nicConfParam := QemuDeviceParam{}
// Set Nic name.
qemuNicName := "net" + strconv.Itoa(nicID)
// Set Mac address.
if nicConfMap["macaddr"] == nil || nicConfMap["macaddr"].(string) == "" {
// Generate Mac based on VmID and NicID so it will be the same always.
macaddr := make(net.HardwareAddr, 6)
rand.Seed(time.Now().UnixNano())
rand.Read(macaddr)
macaddr[0] = (macaddr[0] | 2) & 0xfe // fix from github issue #18
macAddrUppr := strings.ToUpper(fmt.Sprintf("%v", macaddr))
// use model=mac format for older proxmox compatability
macAddr := fmt.Sprintf("%v=%v", nicConfMap["model"], macAddrUppr)
// Add Mac to source map so it will be returned. (useful for some use case like Terraform)
nicConfMap["macaddr"] = macAddrUppr
// and also add it to the parameters which will be sent to Proxmox API.
nicConfParam = append(nicConfParam, macAddr)
} else {
macAddr := fmt.Sprintf("%v=%v", nicConfMap["model"], nicConfMap["macaddr"].(string))
nicConfParam = append(nicConfParam, macAddr)
}
// Set bridge if not nat.
if nicConfMap["bridge"].(string) != "nat" {
bridge := fmt.Sprintf("bridge=%v", nicConfMap["bridge"])
nicConfParam = append(nicConfParam, bridge)
}
// Keys that are not used as real/direct conf.
ignoredKeys := []string{"id", "bridge", "macaddr", "model"}
// Rest of config.
nicConfParam = nicConfParam.createDeviceParam(nicConfMap, ignoredKeys)
// Add nic to Qemu prams.
params[qemuNicName] = strings.Join(nicConfParam, ",")
}
return nil
}
// Create parameters for each disk.
func (c ConfigQemu) CreateQemuDisksParams(
vmID int,
params map[string]interface{},
cloned bool,
) error {
// For backward compatibility.
if len(c.QemuDisks) == 0 && len(c.Storage) > 0 {
dType := c.StorageType
if dType == "" {
if c.HasCloudInit() {
dType = "scsi"
} else {
dType = "virtio"
}
}
deprecatedStyleMap := QemuDevice{
"type": dType,
"storage": c.Storage,
"size": c.DiskSize,
"storage_type": "lvm", // default old style
"cache": "none", // default old value
}
c.QemuDisks[0] = deprecatedStyleMap
}
// For new style with multi disk device.
for diskID, diskConfMap := range c.QemuDisks {
// skip the first disk for clones (may not always be right, but a template probably has at least 1 disk)
if diskID == 0 && cloned {
continue
}
diskConfParam := QemuDeviceParam{
"media=disk",
}
// Device name.
deviceType := diskConfMap["type"].(string)
qemuDiskName := deviceType + strconv.Itoa(diskID)
// Set disk storage.
// Disk size.
diskSizeGB := fmt.Sprintf("size=%v", diskConfMap["size"])
diskConfParam = append(diskConfParam, diskSizeGB)
// Disk name.
var diskFile string
// Currently ZFS local, LVM, and Directory are considered.
// Other formats are not verified, but could be added if they're needed.
rxStorageTypes := `(zfspool|lvm)`
storageType := diskConfMap["storage_type"].(string)
if matched, _ := regexp.MatchString(rxStorageTypes, storageType); matched {
diskFile = fmt.Sprintf("file=%v:vm-%v-disk-%v", diskConfMap["storage"], vmID, diskID)
} else {
diskFile = fmt.Sprintf("file=%v:%v/vm-%v-disk-%v.%v", diskConfMap["storage"], vmID, vmID, diskID, diskConfMap["format"])
}
diskConfParam = append(diskConfParam, diskFile)
// Set cache if not none (default).
if diskConfMap["cache"].(string) != "none" {
diskCache := fmt.Sprintf("cache=%v", diskConfMap["cache"])
diskConfParam = append(diskConfParam, diskCache)
}
// Keys that are not used as real/direct conf.
ignoredKeys := []string{"id", "type", "storage", "storage_type", "size", "cache"}
// Rest of config.
diskConfParam = diskConfParam.createDeviceParam(diskConfMap, ignoredKeys)
// Add back to Qemu prams.
params[qemuDiskName] = strings.Join(diskConfParam, ",")
}
return nil
}
// Create the parameters for each device that will be sent to Proxmox API.
func (p QemuDeviceParam) createDeviceParam(
deviceConfMap QemuDevice,
ignoredKeys []string,
) QemuDeviceParam {
for key, value := range deviceConfMap {
if ignored := inArray(ignoredKeys, key); !ignored {
var confValue interface{}
if bValue, ok := value.(bool); ok && bValue {
confValue = "1"
} else if sValue, ok := value.(string); ok && len(sValue) > 0 {
confValue = sValue
} else if iValue, ok := value.(int); ok && iValue > 0 {
confValue = iValue
}
if confValue != nil {
deviceConf := fmt.Sprintf("%v=%v", key, confValue)
p = append(p, deviceConf)
}
}
}
return p
}
// readDeviceConfig - get standard sub-conf strings where `key=value` and update conf map.
func (confMap QemuDevice) readDeviceConfig(confList []string) error {
// Add device config.
for _, conf := range confList {
key, value := ParseSubConf(conf, "=")
confMap[key] = value
}
return nil
}
func (c ConfigQemu) String() string {
jsConf, _ := json.Marshal(c)
return string(jsConf)
}

@ -0,0 +1,319 @@
package proxmox
// inspired by https://github.com/openstack/golang-client/blob/master/openstack/session.go
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/http/httputil"
"net/url"
)
var Debug = new(bool)
type Response struct {
Resp *http.Response
Body []byte
}
type Session struct {
httpClient *http.Client
ApiUrl string
AuthTicket string
CsrfToken string
Headers http.Header
}
func NewSession(apiUrl string, hclient *http.Client, tls *tls.Config) (session *Session, err error) {
if hclient == nil {
// Only build a transport if we're also building the client
tr := &http.Transport{
TLSClientConfig: tls,
DisableCompression: true,
}
hclient = &http.Client{Transport: tr}
}
session = &Session{
httpClient: hclient,
ApiUrl: apiUrl,
AuthTicket: "",
CsrfToken: "",
Headers: http.Header{},
}
return session, nil
}
func ParamsToBody(params map[string]interface{}) (body []byte) {
vals := url.Values{}
for k, intrV := range params {
var v string
switch intrV.(type) {
// Convert true/false bool to 1/0 string where Proxmox API can understand it.
case bool:
if intrV.(bool) {
v = "1"
} else {
v = "0"
}
default:
v = fmt.Sprintf("%v", intrV)
}
vals.Set(k, v)
}
body = bytes.NewBufferString(vals.Encode()).Bytes()
return
}
func decodeResponse(resp *http.Response, v interface{}) error {
if resp.Body == nil {
return nil
}
rbody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %s", err)
}
if err = json.Unmarshal(rbody, &v); err != nil {
return err
}
return nil
}
func ResponseJSON(resp *http.Response) (jbody map[string]interface{}, err error) {
err = decodeResponse(resp, &jbody)
return jbody, err
}
func TypedResponse(resp *http.Response, v interface{}) error {
var intermediate struct {
Data struct {
Result json.RawMessage `json:"result"`
} `json:"data"`
}
err := decodeResponse(resp, &intermediate)
if err != nil {
return fmt.Errorf("error reading response envelope: %v", err)
}
if err = json.Unmarshal(intermediate.Data.Result, v); err != nil {
return fmt.Errorf("error unmarshalling result %v", err)
}
return nil
}
func (s *Session) Login(username string, password string) (err error) {
reqbody := ParamsToBody(map[string]interface{}{"username": username, "password": password})
olddebug := *Debug
*Debug = false // don't share passwords in debug log
resp, err := s.Post("/access/ticket", nil, nil, &reqbody)
*Debug = olddebug
if err != nil {
return err
}
if resp == nil {
return errors.New("Login error reading response")
}
dr, _ := httputil.DumpResponse(resp, true)
jbody, err := ResponseJSON(resp)
if err != nil {
return err
}
if jbody == nil || jbody["data"] == nil {
return fmt.Errorf("Invalid login response:\n-----\n%s\n-----", dr)
}
dat := jbody["data"].(map[string]interface{})
s.AuthTicket = dat["ticket"].(string)
s.CsrfToken = dat["CSRFPreventionToken"].(string)
return nil
}
func (s *Session) NewRequest(method, url string, headers *http.Header, body io.Reader) (req *http.Request, err error) {
req, err = http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
if headers != nil {
req.Header = *headers
}
if s.AuthTicket != "" {
req.Header.Add("Cookie", "PVEAuthCookie="+s.AuthTicket)
req.Header.Add("CSRFPreventionToken", s.CsrfToken)
}
return
}
func (s *Session) Do(req *http.Request) (*http.Response, error) {
// Add session headers
for k := range s.Headers {
req.Header.Set(k, s.Headers.Get(k))
}
if *Debug {
d, _ := httputil.DumpRequestOut(req, true)
log.Printf(">>>>>>>>>> REQUEST:\n", string(d))
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
if *Debug {
dr, _ := httputil.DumpResponse(resp, true)
log.Printf("<<<<<<<<<< RESULT:\n", string(dr))
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return resp, errors.New(resp.Status)
}
return resp, nil
}
// Perform a simple get to an endpoint
func (s *Session) Request(
method string,
url string,
params *url.Values,
headers *http.Header,
body *[]byte,
) (resp *http.Response, err error) {
// add params to url here
url = s.ApiUrl + url
if params != nil {
url = url + "?" + params.Encode()
}
// Get the body if one is present
var buf io.Reader
if body != nil {
buf = bytes.NewReader(*body)
}
req, err := s.NewRequest(method, url, headers, buf)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
return s.Do(req)
}
// Perform a simple get to an endpoint and unmarshall returned JSON
func (s *Session) RequestJSON(
method string,
url string,
params *url.Values,
headers *http.Header,
body interface{},
responseContainer interface{},
) (resp *http.Response, err error) {
var bodyjson []byte
if body != nil {
bodyjson, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
// if headers == nil {
// headers = &http.Header{}
// headers.Add("Content-Type", "application/json")
// }
resp, err = s.Request(method, url, params, headers, &bodyjson)
if err != nil {
return resp, err
}
// err = util.CheckHTTPResponseStatusCode(resp)
// if err != nil {
// return nil, err
// }
rbody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return resp, errors.New("error reading response body")
}
if err = json.Unmarshal(rbody, &responseContainer); err != nil {
return resp, err
}
return resp, nil
}
func (s *Session) Delete(
url string,
params *url.Values,
headers *http.Header,
) (resp *http.Response, err error) {
return s.Request("DELETE", url, params, headers, nil)
}
func (s *Session) Get(
url string,
params *url.Values,
headers *http.Header,
) (resp *http.Response, err error) {
return s.Request("GET", url, params, headers, nil)
}
func (s *Session) GetJSON(
url string,
params *url.Values,
headers *http.Header,
responseContainer interface{},
) (resp *http.Response, err error) {
return s.RequestJSON("GET", url, params, headers, nil, responseContainer)
}
func (s *Session) Head(
url string,
params *url.Values,
headers *http.Header,
) (resp *http.Response, err error) {
return s.Request("HEAD", url, params, headers, nil)
}
func (s *Session) Post(
url string,
params *url.Values,
headers *http.Header,
body *[]byte,
) (resp *http.Response, err error) {
if headers == nil {
headers = &http.Header{}
headers.Add("Content-Type", "application/x-www-form-urlencoded")
}
return s.Request("POST", url, params, headers, body)
}
func (s *Session) PostJSON(
url string,
params *url.Values,
headers *http.Header,
body interface{},
responseContainer interface{},
) (resp *http.Response, err error) {
return s.RequestJSON("POST", url, params, headers, body, responseContainer)
}
func (s *Session) Put(
url string,
params *url.Values,
headers *http.Header,
body *[]byte,
) (resp *http.Response, err error) {
if headers == nil {
headers = &http.Header{}
headers.Add("Content-Type", "application/x-www-form-urlencoded")
}
return s.Request("PUT", url, params, headers, body)
}

@ -0,0 +1,62 @@
package proxmox
import (
"strconv"
"strings"
)
func inArray(arr []string, str string) bool {
for _, elem := range arr {
if elem == str {
return true
}
}
return false
}
func Itob(i int) bool {
if i == 1 {
return true
}
return false
}
// ParseSubConf - Parse standard sub-conf strings `key=value`.
func ParseSubConf(
element string,
separator string,
) (key string, value interface{}) {
if strings.Contains(element, separator) {
conf := strings.Split(element, separator)
key, value := conf[0], conf[1]
var interValue interface{}
// Make sure to add value in right type,
// because all subconfig are returned as strings from Proxmox API.
if iValue, err := strconv.ParseInt(value, 10, 64); err == nil {
interValue = int(iValue)
} else if bValue, err := strconv.ParseBool(value); err == nil {
interValue = bValue
} else {
interValue = value
}
return key, interValue
}
return
}
// ParseConf - Parse standard device conf string `key1=val1,key2=val2`.
func ParseConf(
kvString string,
confSeparator string,
subConfSeparator string,
) QemuDevice {
var confMap = QemuDevice{}
confList := strings.Split(kvString, confSeparator)
for _, item := range confList {
key, value := ParseSubConf(item, subConfSeparator)
confMap[key] = value
}
return confMap
}

@ -230,6 +230,12 @@
"revision": "c2e73f942591b0f033a3c6df00f44badb2347c38",
"revisionTime": "2018-01-10T05:50:12Z"
},
{
"checksumSHA1": "xp5QPaPy/Viwiv5P8lsJVYlaQ+U=",
"path": "github.com/Telmate/proxmox-api-go/proxmox",
"revision": "7402b5d3034a3e4dea5af6afdfd106a2e353f2da",
"revisionTime": "2019-03-16T14:21:38Z"
},
{
"checksumSHA1": "HttiPj314X1a0i2Jen1p6lRH/vE=",
"path": "github.com/aliyun/aliyun-oss-go-sdk/oss",

Loading…
Cancel
Save